From 3e7ed53847b75f4b88f7f0c4348c0f81a09e872e Mon Sep 17 00:00:00 2001 From: Marvin Charles Date: Mon, 20 Apr 2026 20:27:00 +0300 Subject: [PATCH 1/4] cutover files from source repo Migrate ProtoFleet codebase from the source repository, including GitHub workflows, issue templates, documentation, hermit-managed tooling, and the full project source tree. --- .github/ISSUE_TEMPLATE/bug-report.yml | 79 + .github/ISSUE_TEMPLATE/config.yml | 10 +- .github/ISSUE_TEMPLATE/feature-request.yml | 59 + .../ISSUE_TEMPLATE/miner-compatibility.yml | 55 + .github/actions/deploy-protofleet/action.yml | 129 + .github/actions/go-cache-setup/action.yml | 16 + .github/actions/hermit-setup/action.yml | 25 + .github/client-e2e-tests.yml | 27 + .github/copilot-instructions.md | 160 + .github/dependabot.yml | 26 + .github/labeler.yml | 19 + .github/release-configs/web.json | 37 + .github/workflows/RASPBERRY_PI_DEPLOYMENT.md | 255 + .github/workflows/asicrs-plugin-checks.yml | 98 + .github/workflows/codex-security-review.yml | 286 + .github/workflows/generated-code-check.yml | 45 + .github/workflows/powershell-lint.yml | 85 + .../protofleet-antminer-plugin-checks.yml | 64 + .../workflows/protofleet-client-checks.yml | 113 + .../workflows/protofleet-contract-checks.yml | 62 + .github/workflows/protofleet-deploy-to-pi.yml | 217 + .../protofleet-e2e-real-miners-manual.yml | 135 + .github/workflows/protofleet-e2e-tests.yml | 406 + ...rotofleet-example-python-plugin-checks.yml | 55 + .github/workflows/protofleet-proto-checks.yml | 41 + .../protofleet-proto-plugin-checks.yml | 92 + .../workflows/protofleet-server-checks.yml | 63 + .../protofleet-virtual-plugin-checks.yml | 57 + .github/workflows/protoos-e2e-tests.yml | 157 + .github/workflows/pull-request.yml | 32 + .github/workflows/python-tests.yml | 140 + .github/workflows/release.yml | 538 + .github/workflows/rust-sdk-checks.yml | 105 + .github/workflows/windows-csharp-checks.yml | 70 + .gitignore | 37 + .vscode/settings.json | 38 + CODE_OF_CONDUCT.md | 135 + CONTRIBUTING.md | 222 + GOVERNANCE.md | 60 +- LICENSE | 20 +- Procfile | 2 + README.md | 129 +- SECURITY.md | 28 + bin/.buf-1.57.2.pkg | 1 + bin/.gh-2.53.0.pkg | 1 + bin/.git-absorb-0.7.0.pkg | 1 + bin/.go-1.25.4.pkg | 1 + bin/.goimports-0.3.0.pkg | 1 + bin/.golangci-lint-2.6.2.pkg | 1 + bin/.grpcurl-1.9.2.pkg | 1 + bin/.jq-1.7.1.pkg | 1 + bin/.just-1.40.0.pkg | 1 + bin/.lefthook-2.1.4.pkg | 1 + bin/.migrate-4.18.2.pkg | 1 + bin/.mockgen-1.6.0.pkg | 1 + bin/.mysql-client-8.0.36.pkg | 1 + bin/.nfpm-2.45.0.pkg | 1 + bin/.node-22.14.0.pkg | 1 + bin/.proto-python-gen-0.2.0.pkg | 1 + bin/.protoc-30.2.pkg | 1 + bin/.protoc-gen-connect-go-1.12.0.pkg | 1 + bin/.protoc-gen-go-1.36.5.pkg | 1 + bin/.protoc-gen-go-grpc-1.3.0.pkg | 1 + bin/.python3-3.13.2.pkg | 1 + bin/.sqlc-1.28.0.pkg | 1 + bin/README.hermit.md | 7 + bin/activate-hermit | 21 + bin/activate-hermit.fish | 24 + bin/buf | 1 + bin/corepack | 1 + bin/gh | 1 + bin/git-absorb | 1 + bin/go | 1 + bin/gofmt | 1 + bin/goimports | 1 + bin/golangci-lint | 1 + bin/grpcurl | 1 + bin/hermit | 43 + bin/hermit.hcl | 8 + bin/jq | 1 + bin/just | 1 + bin/lefthook | 1 + bin/migrate | 1 + bin/mockgen | 1 + bin/mysql | 1 + bin/nfpm | 1 + bin/node | 1 + bin/npm | 1 + bin/npx | 1 + bin/pip | 1 + bin/pip3 | 1 + bin/pip3.13 | 1 + bin/protoc | 1 + bin/protoc-gen-buf-breaking | 1 + bin/protoc-gen-buf-lint | 1 + bin/protoc-gen-connect-go | 1 + bin/protoc-gen-go | 1 + bin/protoc-gen-go-grpc | 1 + bin/protoc-gen-python-grpc | 1 + bin/pydoc3 | 1 + bin/pydoc3.13 | 1 + bin/python | 1 + bin/python3 | 1 + bin/python3-config | 1 + bin/python3.13 | 1 + bin/python3.13-config | 1 + bin/sqlc | 1 + buf.gen.yaml | 23 + buf.lock | 9 + buf.yaml | 13 + client/.dockerignore | 1 + client/.gitignore | 35 + client/.npmrc | 1 + client/.prettierignore | 16 + client/.prettierrc.js | 15 + client/.storybook/main.ts | 9 + client/.storybook/preview-body.html | 13 + client/.storybook/preview.tsx | 82 + client/README.md | 218 + client/e2eTests/protoFleet/.gitignore | 28 + client/e2eTests/protoFleet/README.md | 374 + .../protoFleet/config/test.config.defaults.ts | 28 + .../config/test.config.local.example.ts | 26 + .../e2eTests/protoFleet/config/test.config.ts | 65 + .../protoFleet/fixtures/pageFixtures.ts | 96 + .../protoFleet/helpers/commonSteps.ts | 28 + .../protoFleet/helpers/minerModels.ts | 2 + .../protoFleet/helpers/testDataHelper.ts | 19 + client/e2eTests/protoFleet/pages/addMiners.ts | 144 + client/e2eTests/protoFleet/pages/auth.ts | 64 + client/e2eTests/protoFleet/pages/base.ts | 246 + .../protoFleet/pages/components/loginModal.ts | 16 + .../components/modalMinerSelectionList.ts | 95 + client/e2eTests/protoFleet/pages/editPool.ts | 78 + client/e2eTests/protoFleet/pages/groups.ts | 253 + client/e2eTests/protoFleet/pages/home.ts | 122 + client/e2eTests/protoFleet/pages/miners.ts | 940 ++ .../e2eTests/protoFleet/pages/newPoolModal.ts | 44 + client/e2eTests/protoFleet/pages/racks.ts | 532 + client/e2eTests/protoFleet/pages/settings.ts | 36 + .../protoFleet/pages/settingsApiKeys.ts | 155 + .../protoFleet/pages/settingsFirmware.ts | 61 + .../protoFleet/pages/settingsPools.ts | 35 + .../protoFleet/pages/settingsSchedules.ts | 224 + .../protoFleet/pages/settingsSecurity.ts | 52 + .../e2eTests/protoFleet/pages/settingsTeam.ts | 110 + .../e2eTests/protoFleet/playwright.config.ts | 71 + .../protoFleet/spec/00-onboarding.spec.ts | 184 + .../protoFleet/spec/01-miningPools.spec.ts | 286 + .../spec/addMinersValidation.spec.ts | 181 + .../protoFleet/spec/apiKeysSettings.spec.ts | 110 + client/e2eTests/protoFleet/spec/auth.spec.ts | 24 + .../e2eTests/protoFleet/spec/firmware.spec.ts | 128 + .../protoFleet/spec/generalSettings.spec.ts | 79 + .../e2eTests/protoFleet/spec/groups.spec.ts | 345 + .../protoFleet/spec/minerIssues.spec.ts | 219 + .../protoFleet/spec/minersActions.spec.ts | 362 + .../protoFleet/spec/minersAddRemove.spec.ts | 222 + .../protoFleet/spec/minersRename.spec.ts | 434 + .../protoFleet/spec/minersSleepWake.spec.ts | 194 + .../protoFleet/spec/navigation.spec.ts | 110 + client/e2eTests/protoFleet/spec/racks.spec.ts | 578 + .../protoFleet/spec/schedulesSettings.spec.ts | 132 + .../protoFleet/spec/securitySettings.spec.ts | 147 + .../protoFleet/spec/teamAccounts.spec.ts | 245 + client/e2eTests/protoOS/.gitignore | 28 + client/e2eTests/protoOS/README.md | 1 + .../protoOS/config/test.config.defaults.ts | 18 + .../protoOS/config/test.config.local.d.ts | 7 + .../config/test.config.local.example.ts | 28 + client/e2eTests/protoOS/config/test.config.ts | 24 + .../e2eTests/protoOS/fixtures/pageFixtures.ts | 92 + .../e2eTests/protoOS/helpers/commonSteps.ts | 98 + .../protoOS/helpers/testDataHelper.ts | 8 + .../e2eTests/protoOS/pages/authentication.ts | 3 + client/e2eTests/protoOS/pages/base.ts | 77 + .../protoOS/pages/components/header.ts | 23 + .../protoOS/pages/components/navigation.ts | 75 + .../pages/components/sleepWakeDialog.ts | 39 + .../protoOS/pages/components/wakeCallout.ts | 20 + client/e2eTests/protoOS/pages/cooling.ts | 3 + client/e2eTests/protoOS/pages/diagnostics.ts | 135 + client/e2eTests/protoOS/pages/general.ts | 32 + client/e2eTests/protoOS/pages/hardware.ts | 3 + client/e2eTests/protoOS/pages/home.ts | 153 + client/e2eTests/protoOS/pages/logs.ts | 3 + client/e2eTests/protoOS/pages/onboarding.ts | 58 + client/e2eTests/protoOS/pages/pools.ts | 117 + client/e2eTests/protoOS/playwright.config.ts | 69 + .../protoOS/spec/00-onboarding.spec.ts | 83 + .../e2eTests/protoOS/spec/dashboard.spec.ts | 128 + .../e2eTests/protoOS/spec/diagnostics.spec.ts | 123 + client/e2eTests/protoOS/spec/pools.spec.ts | 106 + client/e2eTests/protoOS/spec/power.spec.ts | 119 + .../e2eTests/protoOS/spec/temperature.spec.ts | 79 + client/eslint.config.js | 178 + client/nfpm-proto-os.yaml | 13 + client/nginx.runner-protofleet.conf | 21 + client/package-lock.json | 10922 +++++++++++++++ client/package.json | 95 + client/postcss.config.js | 5 + client/public/favicon.png | Bin 0 -> 2184 bytes .../fonts/Inter/InterVariable-Italic.woff2 | Bin 0 -> 380904 bytes client/public/fonts/Inter/InterVariable.woff2 | Bin 0 -> 345588 bytes .../JetBrainsMono-Italic[wght].ttf | Bin 0 -> 308888 bytes .../JetBrainsMono/JetBrainsMono[wght].ttf | Bin 0 -> 303144 bytes client/scripts/auth_discover_pair.ts | 341 + client/scripts/dev-protoOS.ts | 24 + client/scripts/generate_api_ts.mjs | 42 + .../src/protoFleet/api/ScheduleApiContext.ts | 15 + .../protoFleet/api/ScheduleApiProvider.tsx | 10 + client/src/protoFleet/api/clients.ts | 48 + client/src/protoFleet/api/constants.ts | 1 + .../api/fetchAllMinerSnapshots.test.ts | 100 + .../protoFleet/api/fetchAllMinerSnapshots.ts | 42 + .../api/generated/activity/v1/activity_pb.ts | 455 + .../api/generated/apikey/v1/apikey_pb.ts | 237 + .../api/generated/auth/v1/auth_pb.ts | 660 + .../api/generated/buf/validate/validate_pb.ts | 4801 +++++++ .../capabilities/v1/capabilities_pb.ts | 335 + .../generated/collection/v1/collection_pb.ts | 1771 +++ .../api/generated/common/v1/common_pb.ts | 71 + .../api/generated/common/v1/cooling_pb.ts | 47 + .../generated/common/v1/device_selector_pb.ts | 77 + .../api/generated/common/v1/measurement_pb.ts | 121 + .../api/generated/common/v1/sort_pb.ts | 189 + .../generated/device_set/v1/device_set_pb.ts | 1768 +++ .../api/generated/errors/v1/errors_pb.ts | 1244 ++ .../fleetmanagement/v1/fleetmanagement_pb.ts | 1620 +++ .../v1/fleetperformance_pb.ts | 622 + .../foremanimport/v1/foremanimport_pb.ts | 222 + .../generated/minercommand/v1/command_pb.ts | 1172 ++ .../networkinfo/v1/networkinfo_pb.ts | 175 + .../generated/onboarding/v1/onboarding_pb.ts | 190 + .../api/generated/pairing/v1/pairing_pb.ts | 433 + .../api/generated/ping/v1/ping_pb.ts | 148 + .../api/generated/pools/v1/pools_pb.ts | 444 + .../api/generated/schedule/v1/schedule_pb.ts | 942 ++ .../generated/telemetry/v1/telemetry_pb.ts | 1066 ++ .../protoFleet/api/getErrorMessage.test.ts | 86 + client/src/protoFleet/api/getErrorMessage.ts | 10 + client/src/protoFleet/api/scheduleEvents.ts | 9 + client/src/protoFleet/api/transport.ts | 10 + client/src/protoFleet/api/useActivity.test.ts | 223 + client/src/protoFleet/api/useActivity.ts | 159 + .../api/useActivityFilterOptions.ts | 49 + client/src/protoFleet/api/useApiKeys.ts | 120 + client/src/protoFleet/api/useAuth.ts | 137 + .../api/useAuthNeededMiners.test.ts | 143 + .../src/protoFleet/api/useAuthNeededMiners.ts | 77 + .../protoFleet/api/useComponentErrors.test.ts | 247 + .../src/protoFleet/api/useComponentErrors.ts | 269 + client/src/protoFleet/api/useDeviceErrors.ts | 160 + .../api/useDeviceSetStateCounts.test.ts | 132 + .../protoFleet/api/useDeviceSetStateCounts.ts | 130 + .../src/protoFleet/api/useDeviceSets.test.ts | 166 + client/src/protoFleet/api/useDeviceSets.ts | 858 ++ .../src/protoFleet/api/useExportActivity.ts | 59 + .../protoFleet/api/useExportMinerListCsv.ts | 78 + .../src/protoFleet/api/useFileUpload.test.ts | 254 + client/src/protoFleet/api/useFileUpload.ts | 207 + .../src/protoFleet/api/useFirmwareApi.test.ts | 461 + client/src/protoFleet/api/useFirmwareApi.ts | 268 + client/src/protoFleet/api/useFleet.test.ts | 138 + client/src/protoFleet/api/useFleet.ts | 429 + client/src/protoFleet/api/useFleetCounts.ts | 120 + client/src/protoFleet/api/useForemanImport.ts | 91 + client/src/protoFleet/api/useLogin.ts | 80 + client/src/protoFleet/api/useLogout.ts | 36 + client/src/protoFleet/api/useMinerCommand.ts | 500 + .../src/protoFleet/api/useMinerCoolingMode.ts | 38 + .../src/protoFleet/api/useMinerModelGroups.ts | 19 + client/src/protoFleet/api/useMinerPairing.ts | 96 + .../protoFleet/api/useMinerPoolAssignments.ts | 52 + client/src/protoFleet/api/useNetworkInfo.ts | 75 + .../src/protoFleet/api/useOnboardedStatus.ts | 63 + .../src/protoFleet/api/usePoolNeededCount.ts | 108 + client/src/protoFleet/api/usePools.ts | 217 + client/src/protoFleet/api/useRenameMiners.ts | 74 + .../src/protoFleet/api/useScheduleApi.test.ts | 487 + .../api/useScheduleApi.timezone.test.ts | 165 + client/src/protoFleet/api/useScheduleApi.ts | 597 + .../api/useTelemetryMetrics.test.ts | 115 + .../src/protoFleet/api/useTelemetryMetrics.ts | 148 + .../api/useUpdateWorkerNames.test.ts | 125 + .../protoFleet/api/useUpdateWorkerNames.ts | 79 + .../src/protoFleet/api/useUserManagement.ts | 163 + .../protoFleet/components/App/App.test.tsx | 255 + client/src/protoFleet/components/App/App.tsx | 129 + client/src/protoFleet/components/App/index.ts | 3 + .../components/AppLayout/AppLayout.test.tsx | 74 + .../components/AppLayout/AppLayout.tsx | 59 + .../protoFleet/components/AppLayout/index.ts | 3 + .../DeviceSetList/DeviceSetList.test.tsx | 210 + .../DeviceSetList/DeviceSetList.tsx | 146 + .../components/DeviceSetList/StatCell.tsx | 66 + .../components/DeviceSetList/constants.ts | 29 + .../DeviceSetList/deviceSetColConfig.tsx | 115 + .../components/DeviceSetList/index.ts | 4 + .../DeviceSetList/issueFilterConstants.ts | 30 + .../DeviceSetList/sortConfig.test.ts | 44 + .../components/DeviceSetList/sortConfig.ts | 85 + .../FirmwareUploadComponents.tsx | 181 + .../components/FirmwareUpload/index.ts | 3 + .../FirmwareUpload/useFirmwareUpload.test.ts | 317 + .../FirmwareUpload/useFirmwareUpload.ts | 141 + .../protoFleet/components/Footer/Footer.tsx | 15 + .../src/protoFleet/components/Footer/index.ts | 1 + .../FullScreenTwoPaneModal.stories.tsx | 184 + .../FullScreenTwoPaneModal.tsx | 220 + .../FullScreenTwoPaneModal/index.ts | 5 + .../components/LineChart/LineChart.tsx | 37 + .../protoFleet/components/LineChart/index.ts | 1 + .../components/MinerSelectionList.tsx | 436 + .../MiningPools/MiningPoolsForm.stories.tsx | 32 + .../MiningPools/MiningPoolsForm.test.tsx | 93 + .../MiningPools/MiningPoolsForm.tsx | 236 + .../components/MiningPools/index.ts | 3 + .../NavigationMenu/FloatingNavigation.tsx | 53 + .../components/NavigationMenu/Navigation.tsx | 255 + .../NavigationMenu/NavigationMenu.stories.tsx | 26 + .../NavigationMenu/NavigationMenu.test.tsx | 59 + .../NavigationMenu/NavigationMenu.tsx | 25 + .../components/NavigationMenu/constants.ts | 28 + .../components/NavigationMenu/index.ts | 3 + .../components/NoFilterResultsEmptyState.tsx | 30 + .../BankBalance/BankBalance.stories.tsx | 21 + .../BankBalance/BankBalance.test.tsx | 24 + .../PageHeader/BankBalance/BankBalance.tsx | 29 + .../BankBalance/BankBalanceWrapper.tsx | 11 + .../PageHeader/BankBalance/constants.ts | 1 + .../PageHeader/BankBalance/index.ts | 3 + .../BitcoinExchangeRate.stories.tsx | 21 + .../BitcoinExchangeRate.test.tsx | 27 + .../BitcoinExchangeRate.tsx | 29 + .../BitcoinExchangeRateWrapper.tsx | 10 + .../PageHeader/BitcoinExchangeRate/index.ts | 3 + .../LocationSelector.stories.tsx | 18 + .../LocationSelector.test.tsx | 22 + .../LocationSelector/LocationSelector.tsx | 13 + .../LocationSelectorWrapper.tsx | 11 + .../PageHeader/LocationSelector/index.ts | 3 + .../PageHeader/PageHeader.stories.tsx | 214 + .../components/PageHeader/PageHeader.test.tsx | 88 + .../components/PageHeader/PageHeader.tsx | 93 + .../PageHeader/SchedulePill.test.tsx | 210 + .../components/PageHeader/SchedulePill.tsx | 76 + .../PageHeader/SchedulePopover.test.tsx | 73 + .../components/PageHeader/SchedulePopover.tsx | 93 + .../protoFleet/components/PageHeader/index.ts | 3 + .../PageHeader/schedulePillUtils.ts | 205 + .../PageHeader/useSchedulePillData.test.tsx | 68 + .../PageHeader/useSchedulePillData.ts | 81 + .../SecondaryNavigation.stories.tsx | 25 + .../SecondaryNavigation.test.tsx | 50 + .../SecondaryNavigation.tsx | 66 + .../components/SecondaryNavigation/index.ts | 3 + .../SingleMinerWrapper/SingleMinerWrapper.tsx | 41 + .../components/SingleMinerWrapper/index.ts | 3 + .../components/StatusModal/StatusModal.tsx | 272 + .../components/StatusModal/constants.ts | 59 + .../components/StatusModal/hooks/index.ts | 2 + .../StatusModal/hooks/useStatusModalHooks.ts | 5 + .../components/StatusModal/index.ts | 2 + .../components/StatusModal/types.ts | 44 + .../components/StatusModal/utils.ts | 238 + client/src/protoFleet/config/navItems.ts | 90 + client/src/protoFleet/constants/polling.ts | 6 + .../activity/components/ActivityFilters.tsx | 97 + .../activity/components/ActivityTable.tsx | 84 + .../src/protoFleet/features/activity/index.ts | 1 + .../features/activity/pages/ActivityPage.tsx | 203 + .../features/activity/utils/activityIcons.tsx | 67 + .../features/activity/utils/formatLabel.ts | 1 + .../features/activity/utils/formatScope.ts | 13 + .../AuthenticateFleetModal.stories.tsx | 91 + .../AuthenticateFleetModal.tsx | 131 + .../AuthenticateFleetModal/index.ts | 1 + .../AuthenticateMiners.stories.tsx | 32 + .../AuthenticateMiners.test.tsx | 672 + .../AuthenticateMiners/AuthenticateMiners.tsx | 549 + .../AuthenticateMiners/constants.ts | 4 + .../components/AuthenticateMiners/index.ts | 3 + .../components/AuthenticateMiners/types.ts | 15 + .../auth/components/LoginModal/LoginForm.tsx | 142 + .../auth/components/LoginModal/constants.ts | 11 + .../auth/components/LoginModal/index.ts | 4 + .../auth/components/LoginModal/types.ts | 5 + .../components/UpdatePasswordForm.stories.tsx | 126 + .../components/UpdatePasswordForm.test.tsx | 173 + .../auth/components/UpdatePasswordForm.tsx | 121 + .../UpdatePasswordSuccess.stories.tsx | 35 + .../components/UpdatePasswordSuccess.test.tsx | 85 + .../auth/components/UpdatePasswordSuccess.tsx | 29 + .../features/auth/components/index.ts | 2 + .../features/auth/pages/Auth/Auth.tsx | 32 + .../features/auth/pages/Auth/index.ts | 3 + .../pages/UpdatePassword/UpdatePassword.tsx | 84 + .../auth/pages/UpdatePassword/index.ts | 1 + .../ChartWidget/ChartWidget.stories.tsx | 220 + .../ChartWidget/ChartWidget.test.tsx | 109 + .../components/ChartWidget/ChartWidget.tsx | 48 + .../dashboard/components/ChartWidget/index.ts | 3 + .../EfficiencyPanel/EfficiencyPanel.test.tsx | 84 + .../EfficiencyPanel/EfficiencyPanel.tsx | 97 + .../components/EfficiencyPanel/index.ts | 1 + .../components/EfficiencyPanel/utils.test.ts | 24 + .../components/EfficiencyPanel/utils.ts | 28 + .../FleetHealth/FleetHealth.stories.tsx | 136 + .../FleetHealth/FleetHealth.test.tsx | 275 + .../components/FleetHealth/FleetHealth.tsx | 290 + .../dashboard/components/FleetHealth/index.ts | 3 + .../HashratePanel/HashratePanel.tsx | 88 + .../components/HashratePanel/index.ts | 1 + .../components/HashratePanel/utils.test.ts | 54 + .../components/HashratePanel/utils.ts | 65 + .../components/PowerPanel/PowerPanel.test.tsx | 84 + .../components/PowerPanel/PowerPanel.tsx | 96 + .../dashboard/components/PowerPanel/index.ts | 1 + .../components/PowerPanel/utils.test.ts | 24 + .../dashboard/components/PowerPanel/utils.ts | 28 + .../SectionHeading/SectionHeading.stories.tsx | 46 + .../SectionHeading/SectionHeading.test.tsx | 37 + .../SectionHeading/SectionHeading.tsx | 19 + .../components/SectionHeading/index.ts | 1 + .../SegmentedMetricPanel.stories.tsx | 295 + .../SegmentedMetricPanel.tsx | 176 + .../SegmentedMetricPanel/constants.ts | 14 + .../components/SegmentedMetricPanel/index.ts | 3 + .../components/SegmentedMetricPanel/types.ts | 40 + .../SegmentedMetricPanel/utils.test.ts | 657 + .../components/SegmentedMetricPanel/utils.ts | 383 + .../StatusBreakdownPanel.tsx | 64 + .../components/StatusBreakdownPanel/index.ts | 2 + .../TemperaturePanel.stories.tsx | 215 + .../TemperaturePanel/TemperaturePanel.tsx | 81 + .../components/TemperaturePanel/index.ts | 1 + .../components/TemperaturePanel/utils.ts | 30 + .../UptimePanel/UptimePanel.stories.tsx | 255 + .../UptimePanel/UptimePanel.test.tsx | 167 + .../components/UptimePanel/UptimePanel.tsx | 75 + .../dashboard/components/UptimePanel/index.ts | 1 + .../components/UptimePanel/utils.test.ts | 220 + .../dashboard/components/UptimePanel/utils.ts | 35 + .../features/dashboard/constants.ts | 2 + .../protoFleet/features/dashboard/index.ts | 10 + .../features/dashboard/pages/Dashboard.tsx | 166 + .../protoFleet/features/dashboard/types.ts | 39 + .../dashboard/utils/chartDataPadding.test.ts | 149 + .../dashboard/utils/chartDataPadding.ts | 81 + .../dashboard/utils/createMockMetric.ts | 29 + .../features/dashboard/utils/granularity.ts | 38 + .../utils/metricNormalization.test.ts | 56 + .../dashboard/utils/metricNormalization.ts | 57 + .../utils/minerCountSubtitle.test.ts | 54 + .../dashboard/utils/minerCountSubtitle.ts | 15 + .../ActionBar/ActionBar.stories.tsx | 44 + .../components/ActionBar/ActionBar.test.tsx | 151 + .../components/ActionBar/ActionBar.tsx | 101 + .../FleetPoolActionsMenu.tsx | 92 + .../PoolSelectionPage/FleetPoolRow.tsx | 76 + .../PoolSelectionModal.stories.tsx | 35 + .../PoolSelectionModal.test.tsx | 300 + .../PoolSelectionModal/PoolSelectionModal.tsx | 346 + .../PoolSelectionModal/index.ts | 1 + .../PoolSelectionPage.stories.tsx | 38 + .../PoolSelectionPage.test.tsx | 518 + .../PoolSelectionPage/PoolSelectionPage.tsx | 495 + .../PoolSelectionPageWrapper.tsx | 80 + .../PoolsList/PoolsList.stories.tsx | 53 + .../PoolsList/PoolsList.test.tsx | 135 + .../PoolSelectionPage/PoolsList/PoolsList.tsx | 173 + .../PoolSelectionPage/PoolsList/index.ts | 3 + .../PoolSelectionPage/constants.ts | 1 + .../SettingsWidget/PoolSelectionPage/index.ts | 3 + .../SettingsWidget/PoolSelectionPage/types.ts | 6 + .../components/ActionBar/index.ts | 3 + .../BulkActions/BulkActionConfirmDialog.tsx | 48 + .../BulkActions/BulkActionsPopover.tsx | 69 + .../BulkActions/BulkActionsWidget.stories.tsx | 135 + .../BulkActions/BulkActionsWidget.test.tsx | 60 + .../BulkActions/BulkActionsWidget.tsx | 141 + .../UnsupportedMinersModal.stories.tsx | 118 + .../UnsupportedMinersModal.test.tsx | 322 + .../BulkActions/UnsupportedMinersModal.tsx | 92 + .../components/BulkActions/index.ts | 5 + .../components/BulkActions/types.ts | 32 + .../components/Fleet/Fleet.test.tsx | 298 + .../components/Fleet/Fleet.tsx | 265 + .../components/Fleet/constants.ts | 1 + .../fleetManagement/components/Fleet/index.ts | 3 + .../AddToGroupModal.stories.tsx | 86 + .../MinerActionsMenu/AddToGroupModal.tsx | 211 + .../MinerActionsMenu/BulkRenameDialogs.tsx | 118 + .../BulkRenameModal.stories.tsx | 130 + .../MinerActionsMenu/BulkRenameModal.tsx | 627 + .../BulkRenameOptionModals.tsx | 83 + .../BulkRenamePreviewPanel.tsx | 91 + .../BulkRenamePropertyForm.tsx | 168 + .../BulkRenameToasts.stories.tsx | 116 + .../BulkWorkerNameModal.test.tsx | 778 ++ .../MinerActionsMenu/BulkWorkerNameModal.tsx | 1002 ++ .../CoolingModeModal.stories.tsx | 70 + .../CoolingModeModal/CoolingModeModal.tsx | 160 + .../CoolingModeModal/index.ts | 1 + .../FirmwareUpdateModal.stories.tsx | 35 + .../FirmwareUpdateModal.test.tsx | 91 + .../FirmwareUpdateModal.tsx | 200 + .../FirmwareUpdateModal/index.ts | 1 + .../ManagePowerModal.stories.tsx | 66 + .../ManagePowerModal/ManagePowerModal.tsx | 111 + .../ManagePowerModal/index.ts | 1 + .../ManageSecurityModal.test.tsx | 354 + .../ManageSecurity/ManageSecurityModal.tsx | 127 + .../UpdateMinerPasswordModal.stories.tsx | 64 + .../UpdateMinerPasswordModal.test.tsx | 582 + .../UpdateMinerPasswordModal.tsx | 170 + .../MinerActionsMenu/ManageSecurity/index.ts | 2 + .../MinerActionsMenu.stories.tsx | 23 + .../MinerActionsMenu.test.tsx | 668 + .../MinerActionsMenu/MinerActionsMenu.tsx | 361 + .../RenameMinerDialog.stories.tsx | 36 + .../RenameMinerDialog.test.tsx | 279 + .../MinerActionsMenu/RenameMinerDialog.tsx | 106 + .../CustomPropertyOptionsModal.test.tsx | 188 + .../CustomPropertyOptionsModal.tsx | 205 + .../CustomPropertyTypeDropdown.tsx | 113 + .../FixedValueOptionsModal.test.tsx | 116 + .../FixedValueOptionsModal.tsx | 144 + .../HighlightedNamePreview.test.tsx | 22 + .../HighlightedNamePreview.tsx | 71 + .../RenameOptionsModals/InlineRadioGroup.tsx | 54 + .../QualifierOptionsModal.test.tsx | 103 + .../QualifierOptionsModal.tsx | 110 + .../RenameOptionsModal.stories.tsx | 36 + .../RenameOptionsModal.tsx | 80 + .../RenameOptionsModals.stories.tsx | 133 + .../RenameOptionsModals/constants.ts | 13 + .../RenameOptionsModals/index.ts | 14 + .../storyPreviewBuilders.test.ts | 50 + .../storyPreviewBuilders.ts | 60 + .../RenameOptionsModals/types.ts | 43 + .../SingleMinerActionsMenu.stories.tsx | 78 + .../SingleMinerActionsMenu.test.tsx | 828 ++ .../SingleMinerActionsMenu.tsx | 710 + .../UpdateWorkerNameDialog.test.tsx | 158 + .../UpdateWorkerNameDialog.tsx | 113 + .../MinerActionsMenu/actionMenuUtils.ts | 29 + .../bulkRenameDefinitions.test.ts | 202 + .../MinerActionsMenu/bulkRenameDefinitions.ts | 376 + .../bulkRenamePreview.test.ts | 461 + .../MinerActionsMenu/bulkRenamePreview.ts | 426 + .../bulkRenameToastMessages.test.ts | 31 + .../bulkRenameToastMessages.ts | 22 + .../components/MinerActionsMenu/constants.ts | 141 + .../components/MinerActionsMenu/index.ts | 3 + .../useFleetAuthentication.ts | 53 + .../MinerActionsMenu/useManageSecurityFlow.ts | 340 + .../MinerActionsMenu/useMinerActions.test.tsx | 3613 +++++ .../MinerActionsMenu/useMinerActions.tsx | 1621 +++ .../waitForWorkerNameBatchResult.ts | 48 + .../MinerList/ManageColumnsModal.tsx | 165 + .../components/MinerList/MinerEfficiency.tsx | 22 + .../MinerList/MinerFirmware.test.tsx | 64 + .../components/MinerList/MinerFirmware.tsx | 12 + .../components/MinerList/MinerGroups.tsx | 90 + .../components/MinerList/MinerHashrate.tsx | 15 + .../MinerList/MinerIpAddress.test.tsx | 75 + .../components/MinerList/MinerIpAddress.tsx | 24 + .../components/MinerList/MinerIssues.tsx | 131 + .../MinerList/MinerIssuesCell.test.tsx | 59 + .../components/MinerList/MinerIssuesCell.tsx | 21 + .../MinerList/MinerList.modalFlow.test.tsx | 169 + .../components/MinerList/MinerList.test.tsx | 1245 ++ .../components/MinerList/MinerList.tsx | 898 ++ .../MinerList/MinerListActionBar.test.tsx | 126 + .../MinerList/MinerListActionBar.tsx | 119 + .../components/MinerList/MinerMacAddress.tsx | 12 + .../components/MinerList/MinerMeasurement.tsx | 43 + .../components/MinerList/MinerModel.test.tsx | 56 + .../components/MinerList/MinerModel.tsx | 12 + .../components/MinerList/MinerName.test.tsx | 194 + .../components/MinerList/MinerName.tsx | 75 + .../components/MinerList/MinerPowerUsage.tsx | 22 + .../components/MinerList/MinerStatus.test.tsx | 431 + .../components/MinerList/MinerStatus.tsx | 132 + .../MinerList/MinerStatusCell.test.tsx | 59 + .../components/MinerList/MinerStatusCell.tsx | 22 + .../components/MinerList/MinerTemperature.tsx | 46 + .../components/MinerList/MinerWorkerName.tsx | 14 + .../MinerList/UnsupportedMetric.tsx | 30 + .../components/MinerList/constants.ts | 59 + .../components/MinerList/index.ts | 3 + .../components/MinerList/minerColConfig.tsx | 114 + .../minerTableColumnPreferences.test.ts | 60 + .../MinerList/minerTableColumnPreferences.ts | 113 + .../components/MinerList/sortConfig.ts | 42 + .../MinerList/stories/MinerList.stories.tsx | 106 + .../components/MinerList/stories/mocks.ts | 234 + .../MinerList/stories/statusMocks.ts | 476 + .../components/MinerList/types.ts | 11 + .../useMinerTableColumnPreferences.ts | 75 + .../components/MinerList/utils.test.tsx | 55 + .../components/MinerList/utils.tsx | 22 + .../hooks/useBatchOperations.test.ts | 327 + .../hooks/useBatchOperations.ts | 41 + .../features/fleetManagement/index.ts | 3 + .../features/fleetManagement/types.ts | 16 + .../utils/batchStatusCheck.test.ts | 151 + .../fleetManagement/utils/batchStatusCheck.ts | 55 + .../utils/deviceSelector.test.ts | 53 + .../fleetManagement/utils/deviceSelector.ts | 49 + .../utils/filterUrlParams.test.ts | 256 + .../fleetManagement/utils/filterUrlParams.ts | 322 + .../utils/fleetVisiblePairingFilter.test.ts | 73 + .../utils/fleetVisiblePairingFilter.ts | 45 + .../utils/getMinerMeasurement.test.ts | 142 + .../utils/getMinerMeasurement.ts | 50 + .../utils/sortUrlParams.test.ts | 213 + .../fleetManagement/utils/sortUrlParams.ts | 101 + .../components/DeviceSetActionsMenu.test.tsx | 529 + .../components/DeviceSetActionsMenu.tsx | 454 + .../DeviceSetPerformanceSection.tsx | 350 + .../components/GroupModal.stories.tsx | 32 + .../groupManagement/components/GroupModal.tsx | 234 + .../components/GroupsTable/GroupNameCell.tsx | 36 + .../components/GroupsTable/index.ts | 1 + .../features/groupManagement/index.ts | 2 + .../pages/GroupOverviewPage.tsx | 331 + .../groupManagement/pages/GroupsPage.tsx | 244 + .../ComponentErrors.stories.tsx | 146 + .../ComponentErrors/ComponentErrors.test.tsx | 71 + .../ComponentErrors/ComponentErrors.tsx | 68 + .../kpis/components/ComponentErrors/index.ts | 1 + .../FleetErrors/FleetErrors.stories.tsx | 89 + .../FleetErrors/FleetErrors.test.tsx | 68 + .../components/FleetErrors/FleetErrors.tsx | 52 + .../kpis/components/FleetErrors/index.ts | 1 + .../CompleteSetup/CompleteSetup.stories.tsx | 121 + .../CompleteSetup/CompleteSetup.test.tsx | 922 ++ .../CompleteSetup/CompleteSetup.tsx | 485 + .../components/CompleteSetup/index.ts | 3 + .../components/Miners/FoundMiners.tsx | 187 + .../Miners/FoundMinersModal.stories.tsx | 78 + .../components/Miners/FoundMinersModal.tsx | 109 + .../components/Miners/Miners.stories.tsx | 56 + .../onboarding/components/Miners/Miners.tsx | 451 + .../components/Miners/MinersWrapper.test.tsx | 363 + .../components/Miners/MinersWrapper.tsx | 440 + .../Miners/ValidationErrorDialog.tsx | 77 + .../onboarding/components/Miners/index.ts | 3 + .../onboarding/components/Miners/types.ts | 16 + .../components/Security/SecurityPage.tsx | 22 + .../onboarding/components/Security/index.ts | 3 + .../components/Settings/SettingsPage.tsx | 43 + .../onboarding/components/Settings/index.ts | 3 + .../components/Welcome/WelcomePage.tsx | 158 + .../onboarding/components/Welcome/index.ts | 3 + .../features/onboarding/constants.ts | 26 + .../protoFleet/features/onboarding/index.ts | 6 + .../AssignMinersModal.stories.tsx | 211 + .../AssignMinersModal/AssignMinersModal.tsx | 602 + .../ManageMinersModal.stories.tsx | 38 + .../ManageMinersModal.test.tsx | 137 + .../AssignMinersModal/ManageMinersModal.tsx | 91 + .../AssignMinersModal/MinersPane.tsx | 355 + .../components/AssignMinersModal/RackPane.tsx | 252 + .../SearchMinersModal.stories.tsx | 36 + .../AssignMinersModal/SearchMinersModal.tsx | 58 + .../components/AssignMinersModal/index.ts | 2 + .../components/AssignMinersModal/types.ts | 40 + .../RackCard/MiniRackGrid.stories.tsx | 140 + .../components/RackCard/MiniRackGrid.tsx | 68 + .../components/RackCard/RackCard.stories.tsx | 408 + .../components/RackCard/RackCard.tsx | 121 + .../components/RackCard/RackCardGrid.tsx | 11 + .../components/RackCard/index.ts | 5 + .../components/RackCard/types.ts | 2 + .../RackDetailGrid/RackDetailGrid.stories.tsx | 220 + .../RackDetailGrid/RackDetailGrid.tsx | 64 + .../RackDetailGrid/RackDetailSlot.tsx | 90 + .../components/RackDetailGrid/index.ts | 9 + .../components/RackDetailGrid/types.ts | 27 + .../RackHealthModule/RackHealthModule.tsx | 160 + .../components/RackHealthModule/index.ts | 1 + .../components/RackSettingsModal.stories.tsx | 36 + .../components/RackSettingsModal.tsx | 463 + .../components/RackSlotGrid/RackSlot.tsx | 25 + .../RackSlotGrid/RackSlotGrid.stories.tsx | 266 + .../components/RackSlotGrid/RackSlotGrid.tsx | 45 + .../components/RackSlotGrid/index.ts | 3 + .../components/RackSlotGrid/types.ts | 23 + .../features/rackManagement/index.ts | 2 + .../pages/RackOverviewPage.test.tsx | 190 + .../rackManagement/pages/RackOverviewPage.tsx | 490 + .../rackManagement/pages/RacksPage.tsx | 533 + .../utils/rackCardMapper.test.ts | 167 + .../rackManagement/utils/rackCardMapper.ts | 97 + .../rackManagement/utils/slotNumbering.ts | 42 + .../components/AddTeamMemberModal.stories.tsx | 251 + .../components/AddTeamMemberModal.test.tsx | 251 + .../components/AddTeamMemberModal.tsx | 162 + .../features/settings/components/ApiKeys.tsx | 201 + .../settings/components/Auth.stories.tsx | 8 + .../settings/components/Auth.test.tsx | 138 + .../features/settings/components/Auth.tsx | 420 + .../features/settings/components/Cooling.tsx | 9 + .../components/CreateApiKeyModal.stories.tsx | 32 + .../components/CreateApiKeyModal.test.tsx | 196 + .../settings/components/CreateApiKeyModal.tsx | 235 + .../DeactivateUserDialog.stories.tsx | 137 + .../components/DeactivateUserDialog.test.tsx | 98 + .../components/DeactivateUserDialog.tsx | 46 + .../DeleteAllFirmwareDialog.test.tsx | 101 + .../components/DeleteAllFirmwareDialog.tsx | 53 + .../components/DeleteFirmwareDialog.test.tsx | 107 + .../components/DeleteFirmwareDialog.tsx | 47 + .../settings/components/Firmware.test.tsx | 220 + .../features/settings/components/Firmware.tsx | 224 + .../FirmwareUploadDialog.stories.tsx | 35 + .../components/FirmwareUploadDialog.tsx | 82 + .../settings/components/General.test.tsx | 68 + .../features/settings/components/General.tsx | 82 + .../features/settings/components/Hardware.tsx | 9 + .../settings/components/MiningPools.test.tsx | 628 + .../settings/components/MiningPools.tsx | 575 + .../components/MiningPools.utils.test.ts | 115 + .../components/ResetPasswordModal.stories.tsx | 179 + .../components/ResetPasswordModal.test.tsx | 184 + .../components/ResetPasswordModal.tsx | 114 + .../components/RevokeApiKeyDialog.tsx | 46 + .../Schedules/GroupSelectionModal.test.tsx | 121 + .../Schedules/GroupSelectionModal.tsx | 146 + .../Schedules/MinerSelectionModal.stories.tsx | 64 + .../Schedules/MinerSelectionModal.test.tsx | 36 + .../Schedules/MinerSelectionModal.tsx | 49 + .../Schedules/RackSelectionModal.stories.tsx | 64 + .../Schedules/RackSelectionModal.test.tsx | 93 + .../Schedules/RackSelectionModal.tsx | 147 + .../Schedules/ScheduleModal.stories.tsx | 252 + .../Schedules/ScheduleModal.test.tsx | 310 + .../components/Schedules/ScheduleModal.tsx | 925 ++ .../Schedules/SchedulePreview.test.tsx | 98 + .../components/Schedules/SchedulePreview.tsx | 246 + .../Schedules/SchedulesPage.test.tsx | 152 + .../components/Schedules/SchedulesPage.tsx | 305 + .../Schedules/SchedulesTable.stories.tsx | 254 + .../components/Schedules/constants.test.ts | 58 + .../components/Schedules/constants.ts | 231 + .../Schedules/scheduleColConfig.tsx | 60 + .../Schedules/scheduleRunUtils.test.ts | 105 + .../components/Schedules/scheduleRunUtils.ts | 110 + .../Schedules/scheduleValidation.test.ts | 263 + .../Schedules/scheduleValidation.ts | 483 + .../settings/components/SettingsLayout.tsx | 16 + .../features/settings/components/Team.tsx | 255 + .../src/protoFleet/features/settings/index.ts | 11 + .../settings/utils/formatRole.test.ts | 20 + .../features/settings/utils/formatRole.ts | 11 + .../settings/utils/scheduleDateUtils.test.ts | 22 + .../settings/utils/scheduleDateUtils.ts | 180 + .../protoFleet/hooks/useDeviceSetListState.ts | 208 + .../src/protoFleet/hooks/usePageBackground.ts | 24 + client/src/protoFleet/index.html | 14 + client/src/protoFleet/main.tsx | 25 + client/src/protoFleet/mainWrapper.tsx | 8 + client/src/protoFleet/pages/Home/Home.tsx | 9 + client/src/protoFleet/pages/Home/index.ts | 3 + client/src/protoFleet/router.tsx | 187 + client/src/protoFleet/store/hooks/useAuth.ts | 74 + .../store/hooks/useAuthentication.ts | 63 + client/src/protoFleet/store/hooks/useBatch.ts | 32 + .../protoFleet/store/hooks/useOnboarding.ts | 26 + client/src/protoFleet/store/hooks/useUI.ts | 35 + client/src/protoFleet/store/index.ts | 87 + .../src/protoFleet/store/slices/authSlice.ts | 79 + .../src/protoFleet/store/slices/batchSlice.ts | 169 + .../store/slices/onboardingSlice.ts | 62 + .../protoFleet/store/slices/uiSlice.test.ts | 80 + client/src/protoFleet/store/slices/uiSlice.ts | 93 + .../protoFleet/store/useFleetStore.test.ts | 46 + client/src/protoFleet/store/useFleetStore.ts | 206 + .../src/protoFleet/stories/MockedPoolApis.tsx | 121 + client/src/protoFleet/utils/crypto.test.ts | 41 + client/src/protoFleet/utils/crypto.ts | 11 + .../src/protoFleet/utils/minerFilters.test.ts | 93 + client/src/protoFleet/utils/minerFilters.ts | 36 + client/src/protoOS/api/apiResponseTypes.ts | 16 + client/src/protoOS/api/constants.ts | 3 + .../api/defaultPasswordContract.test.ts | 64 + .../protoOS/api/defaultPasswordContract.ts | 33 + client/src/protoOS/api/generatedApi.ts | 3282 +++++ .../src/protoOS/api/hooks/useCoolingStatus.ts | 150 + .../src/protoOS/api/hooks/useCreatePools.ts | 41 + .../protoOS/api/hooks/useDownloadLogs.test.ts | 128 + .../src/protoOS/api/hooks/useDownloadLogs.ts | 19 + client/src/protoOS/api/hooks/useEditPool.ts | 42 + client/src/protoOS/api/hooks/useErrors.ts | 62 + .../protoOS/api/hooks/useFirmwareUpdate.ts | 141 + client/src/protoOS/api/hooks/useHardware.ts | 255 + .../api/hooks/useHashboardStatus.test.ts | 74 + .../protoOS/api/hooks/useHashboardStatus.ts | 154 + client/src/protoOS/api/hooks/useHashboards.ts | 68 + .../protoOS/api/hooks/useLocateSystem.test.ts | 198 + .../src/protoOS/api/hooks/useLocateSystem.ts | 32 + client/src/protoOS/api/hooks/useLogin.test.ts | 56 + client/src/protoOS/api/hooks/useLogin.ts | 53 + .../src/protoOS/api/hooks/useMiningStart.ts | 35 + .../src/protoOS/api/hooks/useMiningStatus.ts | 68 + client/src/protoOS/api/hooks/useMiningStop.ts | 35 + .../src/protoOS/api/hooks/useMiningTarget.ts | 90 + .../src/protoOS/api/hooks/useNetworkInfo.ts | 73 + .../src/protoOS/api/hooks/usePassword.test.ts | 220 + client/src/protoOS/api/hooks/usePassword.ts | 69 + client/src/protoOS/api/hooks/usePoolsInfo.ts | 84 + .../src/protoOS/api/hooks/useRefresh.test.ts | 94 + client/src/protoOS/api/hooks/useRefresh.ts | 65 + client/src/protoOS/api/hooks/useSystemInfo.ts | 78 + client/src/protoOS/api/hooks/useSystemLogs.ts | 44 + .../src/protoOS/api/hooks/useSystemReboot.ts | 38 + .../protoOS/api/hooks/useSystemStatus.test.ts | 197 + .../src/protoOS/api/hooks/useSystemStatus.ts | 104 + client/src/protoOS/api/hooks/useSystemTag.ts | 54 + client/src/protoOS/api/hooks/useTelemetry.ts | 124 + .../protoOS/api/hooks/useTestConnection.ts | 39 + client/src/protoOS/api/hooks/useTimeSeries.ts | 155 + client/src/protoOS/api/index.ts | 66 + .../src/protoOS/components/App/App.test.tsx | 302 + client/src/protoOS/components/App/App.tsx | 386 + .../components/App/AuthenticatedShell.tsx | 50 + .../protoOS/components/App/ErrorCallout.tsx | 57 + .../App/FansDetectedDialog.stories.tsx | 21 + .../components/App/FansDetectedDialog.tsx | 44 + .../components/App/WakeCallout.stories.tsx | 29 + .../components/App/WakeCallout.test.tsx | 323 + .../protoOS/components/App/WakeCallout.tsx | 86 + .../App/WarmingUpCallout.stories.tsx | 33 + .../components/App/WarmingUpCallout.tsx | 16 + client/src/protoOS/components/App/index.ts | 3 + .../components/AppLayout/AppLayout.tsx | 83 + .../src/protoOS/components/AppLayout/index.ts | 3 + .../ContentLayout/DefaultContentLayout.tsx | 11 + .../ContentLayout/FullScreenContentLayout.tsx | 7 + .../ContentLayout/SettingsContentLayout.tsx | 13 + .../protoOS/components/ContentLayout/types.ts | 5 + .../BackupPoolModalWrapper.test.tsx | 56 + .../MiningPools/BackupPoolModalWrapper.tsx | 50 + .../MiningPools/MiningPools.stories.tsx | 67 + .../components/MiningPools/MiningPools.tsx | 39 + .../components/MiningPools/Pools.test.tsx | 80 + .../protoOS/components/MiningPools/Pools.tsx | 424 + .../protoOS/components/MiningPools/index.ts | 7 + .../NavigationMenu/FloatingNavigation.tsx | 70 + .../NavigationMenu/InfoItem/InfoItem.tsx | 25 + .../InfoItem/IpAddressInfo/IpAddressInfo.tsx | 20 + .../InfoItem/IpAddressInfo/index.ts | 4 + .../MacAddressInfo/MacAddressInfo.tsx | 25 + .../InfoItem/MacAddressInfo/index.ts | 4 + .../InfoItem/MinerNameInfo/MinerNameInfo.tsx | 14 + .../InfoItem/MinerNameInfo/index.ts | 4 + .../InfoItem/VersionInfo/VersionInfo.tsx | 20 + .../InfoItem/VersionInfo/index.ts | 4 + .../NavigationMenu/InfoItem/index.ts | 3 + .../NavigationMenu/Navigation.stories.tsx | 86 + .../NavigationMenu/Navigation.test.tsx | 43 + .../components/NavigationMenu/Navigation.tsx | 94 + .../NavigationItem/NavigationItem.tsx | 43 + .../NavigationMenu/NavigationItem/index.ts | 3 + .../NavigationItems/AppNavigationItems.tsx | 110 + .../OnboardingNavigationItems.tsx | 20 + .../NavigationMenu/NavigationItems/index.ts | 4 + .../NavigationMenu/NavigationMenu.tsx | 58 + .../components/NavigationMenu/constants.ts | 16 + .../components/NavigationMenu/index.ts | 6 + .../components/NavigationMenu/types.ts | 6 + .../NoPoolsCallout/NoPoolsCallout.tsx | 27 + .../components/NoPoolsCallout/index.ts | 3 + .../OnboardingHeader.stories.tsx | 22 + .../OnboardingHeader/OnboardingHeader.tsx | 14 + .../components/OnboardingHeader/index.ts | 3 + .../OnboardingSettingUp.stories.tsx | 39 + .../OnboardingSettingUpWrapper.tsx | 100 + .../components/OnboardingSettingUp/index.ts | 3 + .../GlobalActionsPopover.test.tsx | 90 + .../GlobalActions/GlobalActionsPopover.tsx | 56 + .../GlobalActionsWidget.stories.tsx | 18 + .../GlobalActionsWidget.test.tsx | 111 + .../GlobalActions/GlobalActionsWidget.tsx | 49 + .../GlobalActionsWidgetWrapper.test.tsx | 198 + .../GlobalActionsWidgetWrapper.tsx | 84 + .../PageHeader/GlobalActions/index.ts | 3 + .../MinerStatus/MinerStatus.stories.tsx | 151 + .../PageHeader/MinerStatus/MinerStatus.tsx | 25 + .../MinerStatus/MinerStatusWidget.tsx | 42 + .../PageHeader/MinerStatus/index.ts | 3 + .../PageHeader/PageHeader.stories.tsx | 22 + .../components/PageHeader/PageHeader.tsx | 100 + .../PageHeader/PoolStatus/PoolInfoPopover.tsx | 67 + .../PageHeader/PoolStatus/PoolInfoRow.tsx | 30 + .../PoolStatus/PoolStatus.stories.tsx | 86 + .../PageHeader/PoolStatus/PoolStatus.test.tsx | 173 + .../PageHeader/PoolStatus/PoolStatus.tsx | 80 + .../PoolStatus/PoolStatusWrapper.tsx | 23 + .../PageHeader/PoolStatus/PoolWidget.tsx | 26 + .../components/PageHeader/PoolStatus/index.ts | 3 + .../components/PageHeader/PoolStatus/types.ts | 5 + .../PageHeader/PoolStatus/utility.ts | 31 + .../PageHeader/Power/PowerPopover.tsx | 42 + .../PageHeader/Power/PowerWidget.stories.tsx | 42 + .../PageHeader/Power/PowerWidget.test.tsx | 132 + .../PageHeader/Power/PowerWidget.tsx | 176 + .../PageHeader/Power/PowerWidgetWrapper.tsx | 99 + .../components/PageHeader/Power/constants.ts | 5 + .../components/PageHeader/Power/index.ts | 3 + .../PageHeader/PowerTarget/PowerTarget.tsx | 122 + .../PowerTarget/PowerTargetPopover.test.tsx | 191 + .../PowerTarget/PowerTargetPopover.tsx | 173 + .../PowerTarget/PowerTargetWrapper.tsx | 12 + .../PageHeader/PowerTarget/constants.ts | 14 + .../PageHeader/PowerTarget/index.ts | 3 + .../components/PageHeader/WidgetWrapper.tsx | 51 + .../protoOS/components/PageHeader/index.ts | 3 + .../Power/EnteringSleepDialog.stories.tsx | 9 + .../components/Power/EnteringSleepDialog.tsx | 21 + .../components/Power/ExportingLogsDialog.tsx | 29 + .../components/Power/RebootingDialog.tsx | 21 + .../components/Power/WakingDialog.stories.tsx | 9 + .../protoOS/components/Power/WakingDialog.tsx | 21 + .../Power/WarnRebootDialog.stories.tsx | 10 + .../components/Power/WarnRebootDialog.tsx | 38 + .../components/Power/WarnSleepDialog.tsx | 38 + .../components/Power/WarnWakeDialog.tsx | 38 + client/src/protoOS/components/Power/index.ts | 17 + .../StatusModal/StatusModal.stories.tsx | 210 + .../components/StatusModal/StatusModal.tsx | 173 + .../components/StatusModal/hooks/index.ts | 5 + .../StatusModal/hooks/useComponentHardware.ts | 53 + .../hooks/useComponentTelemetry.ts | 73 + .../protoOS/components/StatusModal/index.ts | 6 + .../protoOS/components/StatusModal/types.ts | 34 + .../protoOS/components/StatusModal/utils.ts | 275 + .../MinerHostingContext.tsx | 71 + .../contexts/MinerHostingContext/index.ts | 4 + .../MinerHostingContext/useMinerHosting.ts | 8 + .../features/auth/components/Auth.stories.tsx | 22 + .../protoOS/features/auth/components/Auth.tsx | 171 + .../components/LoginModal/ForgotPassword.tsx | 60 + .../auth/components/LoginModal/LoginForm.tsx | 129 + .../LoginModal/LoginModal.stories.tsx | 12 + .../components/LoginModal/LoginModal.test.tsx | 107 + .../auth/components/LoginModal/LoginModal.tsx | 34 + .../components/LoginModal/ResizeablePanel.tsx | 38 + .../auth/components/LoginModal/index.ts | 3 + .../features/auth/components/constants.ts | 10 + .../protoOS/features/auth/components/index.ts | 4 + .../features/auth/components/style.css | 3 + .../protoOS/features/auth/components/types.ts | 4 + .../components/Card/Card.stories.tsx | 24 + .../diagnostic/components/Card/Card.tsx | 15 + .../diagnostic/components/Card/index.ts | 3 + .../CardHeader/CardHeader.stories.tsx | 79 + .../components/CardHeader/CardHeader.tsx | 43 + .../diagnostic/components/CardHeader/index.ts | 3 + .../ComponentSection.stories.tsx | 31 + .../ComponentSection/ComponentSection.tsx | 19 + .../components/ComponentSection/index.ts | 3 + .../ComponentSelector/ComponentSelector.tsx | 30 + .../components/ComponentSelector/constants.ts | 9 + .../components/ComponentSelector/index.ts | 2 + .../components/ComponentSelector/types.ts | 3 + .../ControlBoardStatusCard.stories.tsx | 78 + .../ControlBoardStatusCard.tsx | 50 + .../ControlBoardStatusCard/index.ts | 2 + .../DiagnosticView/DiagnosticView.test.tsx | 156 + .../DiagnosticView/DiagnosticView.tsx | 224 + .../components/DiagnosticView/index.ts | 1 + .../EmptySlotCard/EmptySlotCard.stories.tsx | 69 + .../EmptySlotCard/EmptySlotCard.tsx | 37 + .../components/EmptySlotCard/index.ts | 1 + .../FanStatusCard/FanStatusCard.stories.tsx | 101 + .../FanStatusCard/FanStatusCard.tsx | 58 + .../components/FanStatusCard/index.ts | 2 + .../HashboardStatusCard.stories.tsx | 173 + .../HashboardStatusCard.tsx | 88 + .../components/HashboardStatusCard/index.ts | 1 + .../HashboardTemperature/Asic/AsicButton.tsx | 133 + .../Asic/AsicPopover/AsicChart/AsicChart.tsx | 100 + .../AsicChart/AsicChartTooltip.tsx | 74 + .../Asic/AsicPopover/AsicChart/constants.ts | 30 + .../Asic/AsicPopover/AsicChart/index.ts | 3 + .../Asic/AsicPopover/AsicChart/types.ts | 4 + .../Asic/AsicPopover/AsicChart/utility.ts | 62 + .../Asic/AsicPopover/AsicPopover.stories.tsx | 45 + .../Asic/AsicPopover/AsicPopover.tsx | 115 + .../Asic/AsicPopover/AsicPopoverRow.tsx | 23 + .../Asic/AsicPopover/AsicPopoverWrapper.tsx | 26 + .../Asic/AsicPopover/constants.ts | 558 + .../Asic/AsicPopover/index.ts | 3 + .../Asic/AsicPopover/utility.test.ts | 35 + .../Asic/AsicPopover/utility.ts | 71 + .../Asic/AsicTable.stories.tsx | 84 + .../HashboardTemperature/Asic/AsicTable.tsx | 56 + .../Asic/AsicTableWrapper.tsx | 31 + .../HashboardTemperature/Asic/constants.ts | 72 + .../HashboardTemperature/Asic/utility.ts | 3 + .../AsicMetricContext.tsx | 30 + .../HashboardSelector.tsx | 69 + .../HashboardTemperature.tsx | 201 + .../HashboardTemperatureWrapper.tsx | 9 + .../components/HashboardTemperature/index.ts | 3 + .../HashboardTemperature/utility.test.ts | 52 + .../HashboardTemperature/utility.ts | 28 + .../components/LabeledValue/LabeledValue.tsx | 26 + .../components/LabeledValue/index.ts | 1 + .../components/MetadataRow/MetadataRow.tsx | 17 + .../components/MetadataRow/index.ts | 1 + .../PsuStatusCard/PsuStatusCard.stories.tsx | 209 + .../PsuStatusCard/PsuStatusCard.tsx | 74 + .../components/PsuStatusCard/index.ts | 2 + .../features/diagnostic/components/index.ts | 1 + .../src/protoOS/features/diagnostic/index.ts | 1 + .../src/protoOS/features/diagnostic/types.ts | 89 + .../CheckForUpdate/CheckForUpdate.tsx | 69 + .../components/CheckForUpdate/index.ts | 3 + .../FirmwareUpdateStatus.stories.tsx | 111 + .../FirmwareUpdateStatus.tsx | 59 + .../FirmwareUpdateStatusWidget.tsx | 62 + .../FirmwareUpdateStatusWrapper.tsx | 89 + .../components/FirmwareUpdateStatus/index.ts | 1 + .../FirmwareUpdateStatusModal.tsx | 249 + .../FirmwareUpdateStatusModal/index.ts | 3 + .../protoOS/features/firmwareUpdate/index.ts | 4 + .../features/firmwareUpdate/utility.ts | 29 + .../kpis/components/Efficiency/Efficiency.tsx | 87 + .../kpis/components/Efficiency/index.ts | 3 + .../HashboardSelector.stories.tsx | 74 + .../HashboardSelector.test.tsx | 415 + .../HashboardSelector/HashboardSelector.tsx | 170 + .../components/HashboardSelector/index.ts | 3 + .../kpis/components/Hashrate/Hashrate.tsx | 87 + .../kpis/components/Hashrate/index.ts | 3 + .../kpis/components/KpiLayout/KpiLayout.tsx | 79 + .../kpis/components/KpiLayout/index.ts | 2 + .../KpiLineChart/KpiLineChart.test.tsx | 111 + .../components/KpiLineChart/KpiLineChart.tsx | 134 + .../kpis/components/KpiLineChart/index.ts | 3 + .../stories/KpiLineChart.stories.tsx | 85 + .../components/KpiLineChart/stories/mocks.ts | 11388 ++++++++++++++++ .../kpis/components/PowerUsage/PowerUsage.tsx | 57 + .../kpis/components/PowerUsage/index.ts | 3 + .../components/TabMenu/TabMenuWrapper.tsx | 60 + .../features/kpis/components/TabMenu/index.ts | 3 + .../components/Temperature/Temperature.tsx | 99 + .../kpis/components/Temperature/index.ts | 3 + client/src/protoOS/features/kpis/constants.ts | 22 + .../src/protoOS/features/kpis/hooks/index.ts | 3 + .../features/kpis/hooks/useAsicColor.ts | 39 + .../protoOS/features/kpis/hooks/utility.ts | 136 + client/src/protoOS/features/kpis/index.ts | 7 + client/src/protoOS/features/kpis/types.ts | 23 + client/src/protoOS/features/kpis/utility.ts | 6 + .../Authentication/Authentication.test.tsx | 163 + .../Authentication/Authentication.tsx | 101 + .../components/Authentication/index.ts | 3 + .../components/MiningPool/MiningPool.test.tsx | 573 + .../components/MiningPool/MiningPool.tsx | 210 + .../onboarding/components/MiningPool/index.ts | 3 + .../onboarding/components/Network/Network.tsx | 25 + .../onboarding/components/Network/index.ts | 3 + .../NoFansDetectedDialog.stories.tsx | 26 + .../NoFansDetectedDialog.tsx | 47 + .../components/NoFansDetectedDialog/index.ts | 3 + .../Onboarding/Onboarding.stories.tsx | 22 + .../components/Onboarding/Onboarding.test.tsx | 589 + .../components/Onboarding/Onboarding.tsx | 156 + .../onboarding/components/Onboarding/index.ts | 3 + .../components/Verify/Verify.stories.tsx | 19 + .../onboarding/components/Verify/Verify.tsx | 58 + .../components/Verify/VerifyWrapper.tsx | 32 + .../onboarding/components/Verify/index.ts | 3 + .../onboarding/components/Welcome/Welcome.tsx | 34 + .../onboarding/components/Welcome/index.ts | 3 + .../src/protoOS/features/onboarding/index.ts | 8 + .../Authentication/Authentication.test.tsx | 77 + .../Authentication/Authentication.tsx | 90 + .../components/Authentication/index.ts | 3 + .../settings/components/Cooling/Cooling.tsx | 261 + .../components/Cooling/InfoModal.stories.tsx | 48 + .../settings/components/Cooling/InfoModal.tsx | 39 + .../settings/components/Cooling/constants.ts | 18 + .../settings/components/Cooling/index.ts | 3 + .../settings/components/General/General.tsx | 181 + .../MinerSystemTagEditModal.stories.tsx | 34 + .../General/MinerSystemTagEditModal.test.tsx | 86 + .../General/MinerSystemTagEditModal.tsx | 102 + .../settings/components/General/index.ts | 3 + .../components/Hardware/Hardware.test.tsx | 220 + .../settings/components/Hardware/Hardware.tsx | 165 + .../settings/components/Hardware/constants.ts | 15 + .../settings/components/Hardware/index.ts | 3 + .../settings/components/Hardware/utility.ts | 17 + .../components/MiningPools/MiningPools.tsx | 206 + .../components/MiningPools/constants.ts | 12 + .../settings/components/MiningPools/index.ts | 3 + client/src/protoOS/features/settings/index.ts | 7 + client/src/protoOS/hooks/status/constants.ts | 160 + client/src/protoOS/hooks/status/index.ts | 9 + .../hooks/status/useComponentDisplayName.ts | 37 + .../hooks/status/useComponentStatusTitle.ts | 47 + .../hooks/status/useMinerStatusCircle.ts | 32 + .../hooks/status/useMinerStatusSummary.ts | 47 + .../hooks/status/useMinerStatusTitle.ts | 53 + .../src/protoOS/hooks/useWakeMiner/index.ts | 1 + .../hooks/useWakeMiner/useWakeMiner.ts | 157 + client/src/protoOS/index.html | 16 + client/src/protoOS/main.tsx | 18 + client/src/protoOS/mainWrapper.tsx | 8 + .../src/protoOS/pages/MinerLogs/LogBadges.tsx | 28 + .../protoOS/pages/MinerLogs/Logs.stories.tsx | 14 + .../src/protoOS/pages/MinerLogs/Logs.test.ts | 85 + client/src/protoOS/pages/MinerLogs/Logs.tsx | 309 + .../protoOS/pages/MinerLogs/LogsWrapper.tsx | 30 + .../src/protoOS/pages/MinerLogs/constants.ts | 525 + client/src/protoOS/pages/MinerLogs/index.ts | 3 + client/src/protoOS/pages/MinerLogs/types.ts | 9 + client/src/protoOS/pages/MinerLogs/utility.ts | 84 + client/src/protoOS/routeAuth.ts | 42 + client/src/protoOS/router.tsx | 161 + client/src/protoOS/store/README.md | 412 + client/src/protoOS/store/hooks/index.ts | 15 + .../src/protoOS/store/hooks/useAuth.test.ts | 345 + client/src/protoOS/store/hooks/useAuth.ts | 211 + .../protoOS/store/hooks/useAuthRetry.test.ts | 212 + .../src/protoOS/store/hooks/useAuthRetry.ts | 61 + client/src/protoOS/store/hooks/useHardware.ts | 140 + client/src/protoOS/store/hooks/useMiner.ts | 289 + .../src/protoOS/store/hooks/useMinerStatus.ts | 196 + .../protoOS/store/hooks/useMiningTarget.ts | 35 + .../src/protoOS/store/hooks/useNetworkInfo.ts | 42 + client/src/protoOS/store/hooks/usePools.ts | 23 + .../src/protoOS/store/hooks/useSystemInfo.ts | 109 + .../src/protoOS/store/hooks/useTelemetry.ts | 30 + client/src/protoOS/store/hooks/useUI.ts | 59 + client/src/protoOS/store/index.ts | 221 + client/src/protoOS/store/slices/authSlice.ts | 58 + .../src/protoOS/store/slices/hardwareSlice.ts | 304 + .../protoOS/store/slices/minerStatusSlice.ts | 141 + .../protoOS/store/slices/miningTargetSlice.ts | 100 + .../protoOS/store/slices/networkInfoSlice.ts | 45 + client/src/protoOS/store/slices/poolsSlice.ts | 39 + .../protoOS/store/slices/systemInfoSlice.ts | 48 + .../protoOS/store/slices/telemetrySlice.ts | 758 + client/src/protoOS/store/slices/uiSlice.ts | 170 + client/src/protoOS/store/types.ts | 250 + client/src/protoOS/store/useMinerStore.ts | 221 + .../src/protoOS/store/utils/coolingUtils.ts | 30 + .../store/utils/errorTransformer.test.ts | 141 + .../protoOS/store/utils/errorTransformer.ts | 36 + client/src/protoOS/store/utils/getAsicId.ts | 8 + client/src/protoOS/store/utils/getAsicName.ts | 17 + .../src/protoOS/store/utils/telemetryUtils.ts | 216 + client/src/shared/assets/icons/Activity.tsx | 20 + client/src/shared/assets/icons/Alert.tsx | 33 + client/src/shared/assets/icons/ArrowDown.tsx | 19 + .../shared/assets/icons/ArrowLeftCompact.tsx | 28 + client/src/shared/assets/icons/ArrowRight.tsx | 28 + client/src/shared/assets/icons/ArrowUp.tsx | 19 + client/src/shared/assets/icons/Asic.tsx | 29 + .../src/shared/assets/icons/BankAccount.tsx | 29 + client/src/shared/assets/icons/Bitcoin.tsx | 20 + client/src/shared/assets/icons/C1Chip.tsx | 30 + client/src/shared/assets/icons/Calendar.tsx | 21 + client/src/shared/assets/icons/Checkmark.tsx | 75 + .../src/shared/assets/icons/ChevronDown.tsx | 21 + .../src/shared/assets/icons/ChevronUpDown.tsx | 21 + client/src/shared/assets/icons/Circle.tsx | 26 + .../shared/assets/icons/ConcentricCircles.tsx | 24 + .../src/shared/assets/icons/ControlBoard.tsx | 20 + client/src/shared/assets/icons/Copy.tsx | 25 + client/src/shared/assets/icons/Curtail.tsx | 28 + client/src/shared/assets/icons/Dismiss.tsx | 29 + .../src/shared/assets/icons/DismissCircle.tsx | 20 + .../shared/assets/icons/DismissCircleDark.tsx | 26 + .../src/shared/assets/icons/DismissTiny.tsx | 21 + client/src/shared/assets/icons/Download.tsx | 28 + client/src/shared/assets/icons/Edit.tsx | 21 + client/src/shared/assets/icons/Efficiency.tsx | 30 + client/src/shared/assets/icons/Ellipsis.tsx | 19 + client/src/shared/assets/icons/Eye.tsx | 20 + client/src/shared/assets/icons/Fan.tsx | 28 + .../src/shared/assets/icons/FanIndicator.tsx | 43 + .../shared/assets/icons/FanIndicatorV2.tsx | 48 + client/src/shared/assets/icons/Fleet.tsx | 20 + .../src/shared/assets/icons/FleetWordmark.tsx | 19 + client/src/shared/assets/icons/Globe.tsx | 28 + client/src/shared/assets/icons/Graph.tsx | 20 + client/src/shared/assets/icons/Grip.test.tsx | 23 + client/src/shared/assets/icons/Grip.tsx | 26 + client/src/shared/assets/icons/Groups.tsx | 22 + client/src/shared/assets/icons/Hashboard.tsx | 19 + .../assets/icons/HashboardIndicator.tsx | 53 + .../assets/icons/HashboardIndicatorV2.tsx | 57 + client/src/shared/assets/icons/Hashrate.tsx | 30 + client/src/shared/assets/icons/Home.tsx | 18 + client/src/shared/assets/icons/Immersion.tsx | 30 + client/src/shared/assets/icons/Info.tsx | 29 + .../src/shared/assets/icons/InfoInverted.tsx | 22 + .../shared/assets/icons/InteractiveIcon.tsx | 47 + .../src/shared/assets/icons/LEDIndicator.tsx | 28 + client/src/shared/assets/icons/Lightning.tsx | 28 + .../src/shared/assets/icons/LightningAlt.tsx | 23 + client/src/shared/assets/icons/Lock.tsx | 26 + client/src/shared/assets/icons/Logo.tsx | 35 + client/src/shared/assets/icons/LogoAlt.tsx | 27 + client/src/shared/assets/icons/Logs.tsx | 21 + client/src/shared/assets/icons/Menu.tsx | 22 + .../src/shared/assets/icons/MiningPools.tsx | 20 + client/src/shared/assets/icons/Minus.tsx | 18 + .../src/shared/assets/icons/Notification.tsx | 29 + .../shared/assets/icons/PartialCheckmark.tsx | 94 + client/src/shared/assets/icons/Pause.tsx | 26 + client/src/shared/assets/icons/Play.tsx | 28 + client/src/shared/assets/icons/Plus.tsx | 27 + client/src/shared/assets/icons/Power.tsx | 29 + .../src/shared/assets/icons/PsuIndicator.tsx | 35 + .../shared/assets/icons/PsuIndicatorV2.tsx | 32 + client/src/shared/assets/icons/Question.tsx | 18 + client/src/shared/assets/icons/Racks.tsx | 20 + client/src/shared/assets/icons/Reboot.tsx | 28 + client/src/shared/assets/icons/Rectangle.tsx | 34 + client/src/shared/assets/icons/Repair.tsx | 21 + client/src/shared/assets/icons/Settings.tsx | 21 + .../src/shared/assets/icons/SettingsSolid.tsx | 20 + client/src/shared/assets/icons/Slider.tsx | 26 + .../src/shared/assets/icons/Speedometer.tsx | 29 + client/src/shared/assets/icons/Stop.tsx | 28 + client/src/shared/assets/icons/Success.tsx | 24 + client/src/shared/assets/icons/Terminal.tsx | 28 + client/src/shared/assets/icons/ThemeDark.tsx | 14 + client/src/shared/assets/icons/ThemeLight.tsx | 29 + .../src/shared/assets/icons/ThemeSystem.tsx | 20 + client/src/shared/assets/icons/Trash.tsx | 49 + client/src/shared/assets/icons/Triangle.tsx | 9 + client/src/shared/assets/icons/Unpair.tsx | 28 + client/src/shared/assets/icons/constants.ts | 6 + .../src/shared/assets/icons/icons.stories.tsx | 24 + client/src/shared/assets/icons/index.tsx | 167 + client/src/shared/assets/icons/types.ts | 19 + client/src/shared/assets/images/ProtoRig.png | Bin 0 -> 60387 bytes .../src/shared/assets/images/ProtoRig_2x.png | Bin 0 -> 191213 bytes client/src/shared/assets/images/miner.png | Bin 0 -> 92801 bytes .../AnimatedDotsBackground.stories.tsx | 29 + .../Animation/AnimatedDotsBackground.tsx | 106 + .../src/shared/components/Animation/index.ts | 3 + .../src/shared/components/Animation/style.css | 42 + .../AsicTablePreview.stories.tsx | 82 + .../AsicTablePreview.test.tsx | 200 + .../AsicTablePreview/AsicTablePreview.tsx | 117 + .../components/AsicTablePreview/index.ts | 2 + .../components/AsicTablePreview/types.ts | 39 + .../BackgroundImage.stories.tsx | 16 + .../BackgroundImage/BackgroundImage.tsx | 47 + .../components/BackgroundImage/index.ts | 3 + .../BuildVersionInfo/BuildVersionInfo.tsx | 41 + .../components/BuildVersionInfo/index.ts | 2 + .../components/Button/Button.stories.tsx | 73 + .../shared/components/Button/Button.test.tsx | 44 + .../src/shared/components/Button/Button.tsx | 148 + .../src/shared/components/Button/constants.ts | 15 + client/src/shared/components/Button/index.ts | 5 + .../components/ButtonGroup/ButtonDivider.tsx | 5 + .../ButtonGroup/ButtonGroup.stories.tsx | 65 + .../components/ButtonGroup/ButtonGroup.tsx | 91 + .../components/ButtonGroup/constants.ts | 8 + .../shared/components/ButtonGroup/index.ts | 7 + .../shared/components/ButtonGroup/types.ts | 18 + .../shared/components/ButtonGroup/utility.ts | 20 + .../components/Callout/Callout.stories.tsx | 112 + .../src/shared/components/Callout/Callout.tsx | 132 + .../Callout/DismissibleCalloutWrapper.tsx | 52 + .../shared/components/Callout/constants.ts | 7 + client/src/shared/components/Callout/index.ts | 6 + .../shared/components/Card/Card.stories.tsx | 22 + client/src/shared/components/Card/Card.tsx | 29 + .../src/shared/components/Card/constants.ts | 5 + client/src/shared/components/Card/index.ts | 5 + .../src/shared/components/Chart/AxisTick.tsx | 25 + .../shared/components/Chart/ChartWrapper.tsx | 22 + .../shared/components/Chart/LineCursor.tsx | 27 + .../src/shared/components/Chart/LineDot.tsx | 74 + .../components/Chart/TimeXAxisTick.test.tsx | 52 + .../shared/components/Chart/TimeXAxisTick.tsx | 146 + .../src/shared/components/Chart/constants.ts | 18 + client/src/shared/components/Chart/index.ts | 8 + client/src/shared/components/Chart/utility.ts | 70 + .../components/Checkbox/Checkbox.stories.tsx | 9 + .../shared/components/Checkbox/Checkbox.tsx | 46 + .../src/shared/components/Checkbox/index.ts | 3 + .../shared/components/Chip/Chip.stories.tsx | 32 + .../src/shared/components/Chip/Chip.test.tsx | 22 + client/src/shared/components/Chip/Chip.tsx | 29 + client/src/shared/components/Chip/index.ts | 3 + .../CompositionBar/CompositionBar.stories.tsx | 99 + .../CompositionBar/CompositionBar.test.tsx | 318 + .../CompositionBar/CompositionBar.tsx | 144 + .../components/CompositionBar/constants.ts | 42 + .../shared/components/CompositionBar/index.ts | 5 + .../shared/components/CompositionBar/types.ts | 32 + .../ContentHeader/ContentHeader.tsx | 22 + .../shared/components/ContentHeader/index.ts | 3 + .../DataNullState/DataNullState.stories.tsx | 16 + .../DataNullState/DataNullState.tsx | 26 + .../shared/components/DataNullState/index.ts | 1 + .../shared/components/DatePicker/Calendar.tsx | 203 + .../DatePicker/DatePicker.stories.tsx | 201 + .../components/DatePicker/DatePicker.test.tsx | 598 + .../components/DatePicker/DatePicker.tsx | 447 + .../DatePicker/DatePickerField.test.tsx | 33 + .../components/DatePicker/DatePickerField.tsx | 99 + .../components/DatePicker/DatePickerInput.tsx | 142 + .../components/DatePicker/PresetList.tsx | 66 + .../shared/components/DatePicker/constants.ts | 62 + .../src/shared/components/DatePicker/index.ts | 6 + .../src/shared/components/DatePicker/types.ts | 47 + .../src/shared/components/DatePicker/utils.ts | 203 + .../components/Dialog/Dialog.stories.tsx | 37 + .../src/shared/components/Dialog/Dialog.tsx | 108 + .../components/Dialog/DialogIcon.test.tsx | 57 + .../shared/components/Dialog/DialogIcon.tsx | 26 + client/src/shared/components/Dialog/index.ts | 5 + .../src/shared/components/Divider/Divider.tsx | 16 + client/src/shared/components/Divider/index.ts | 3 + .../DurationSelector.stories.tsx | 9 + .../DurationSelector/DurationSelector.tsx | 50 + .../DurationSelector/constants.test.ts | 58 + .../components/DurationSelector/constants.ts | 55 + .../components/DurationSelector/index.ts | 26 + .../components/DurationSelector/types.ts | 2 + .../EfficiencyValue/EfficiencyValue.tsx | 20 + .../components/EfficiencyValue/index.ts | 1 + .../components/EmptyValue/EmptyValue.tsx | 15 + .../src/shared/components/EmptyValue/index.ts | 3 + .../ErrorBoundary/DefaultErrorFallback.tsx | 59 + .../ErrorBoundary/ErrorBoundary.stories.tsx | 104 + .../ErrorBoundary/ErrorBoundary.test.tsx | 117 + .../ErrorBoundary/ErrorBoundary.tsx | 74 + .../shared/components/ErrorBoundary/index.ts | 2 + .../shared/components/FanValue/FanValue.tsx | 26 + .../src/shared/components/FanValue/index.ts | 1 + .../FileSizeValue/FileSizeValue.tsx | 11 + .../FileSizeValue/formatFileSize.ts | 6 + .../shared/components/FileSizeValue/index.ts | 2 + .../FleetDown/FleetDown.stories.tsx | 60 + .../components/FleetDown/FleetDown.test.tsx | 127 + .../shared/components/FleetDown/FleetDown.tsx | 92 + .../src/shared/components/FleetDown/index.ts | 3 + .../HashRateValue/HashRateValue.tsx | 26 + .../shared/components/HashRateValue/index.ts | 1 + .../components/Header/Header.stories.tsx | 108 + .../shared/components/Header/Header.test.tsx | 67 + .../src/shared/components/Header/Header.tsx | 140 + client/src/shared/components/Header/index.ts | 3 + .../shared/components/Input/Input.stories.tsx | 90 + .../shared/components/Input/Input.test.tsx | 120 + client/src/shared/components/Input/Input.tsx | 314 + client/src/shared/components/Input/index.ts | 3 + .../shared/components/Input/useValueWidth.tsx | 48 + .../components/LatencyValue/LatencyValue.tsx | 19 + .../shared/components/LatencyValue/index.ts | 1 + .../components/LineChart/LineChart.test.tsx | 351 + .../shared/components/LineChart/LineChart.tsx | 636 + .../LineChart/Tooltip/Tooltip.test.tsx | 314 + .../components/LineChart/Tooltip/Tooltip.tsx | 206 + .../LineChart/Tooltip/TooltipItem.tsx | 35 + .../components/LineChart/Tooltip/index.ts | 4 + .../shared/components/LineChart/constants.ts | 19 + .../src/shared/components/LineChart/index.ts | 6 + .../src/shared/components/LineChart/types.ts | 4 + .../components/List/Filters/ButtonFilter.tsx | 39 + .../List/Filters/DropdownFilter.stories.tsx | 255 + .../List/Filters/DropdownFilter.tsx | 202 + .../List/Filters/DropdownFilterPopover.tsx | 112 + .../List/Filters/Filters.stories.tsx | 304 + .../components/List/Filters/Filters.test.tsx | 184 + .../components/List/Filters/Filters.tsx | 213 + .../shared/components/List/Filters/index.tsx | 3 + .../shared/components/List/Filters/types.ts | 34 + .../shared/components/List/List.stories.tsx | 122 + .../src/shared/components/List/List.test.tsx | 2352 ++++ client/src/shared/components/List/List.tsx | 1392 ++ .../List/ListActions/ListActions.test.tsx | 257 + .../List/ListActions/ListActions.tsx | 97 + .../components/List/ListActions/index.ts | 3 + .../src/shared/components/List/constants.ts | 1 + client/src/shared/components/List/index.ts | 4 + .../components/List/mocks/colConfig.tsx | 58 + .../src/shared/components/List/mocks/data.ts | 128 + client/src/shared/components/List/types.ts | 42 + .../MiningPools/PoolForm/PoolForm.tsx | 164 + .../MiningPools/PoolForm/constants.ts | 15 + .../components/MiningPools/PoolForm/index.ts | 3 + .../MiningPools/PoolModal.stories.tsx | 93 + .../components/MiningPools/PoolModal.test.tsx | 250 + .../components/MiningPools/PoolModal.tsx | 360 + .../components/MiningPools/PoolRow.test.tsx | 52 + .../shared/components/MiningPools/PoolRow.tsx | 93 + .../WarnBackupPoolDialog.tsx | 36 + .../MiningPools/WarnBackupPoolDialog/index.ts | 3 + .../WarnDefaultPoolCallout.tsx | 25 + .../WarnDefaultPoolCallout/index.ts | 3 + .../components/MiningPools/constants.ts | 18 + .../shared/components/MiningPools/types.ts | 17 + .../shared/components/MiningPools/utility.ts | 22 + .../components/MiningPools/validation.ts | 25 + .../shared/components/Modal/Modal.stories.tsx | 285 + client/src/shared/components/Modal/Modal.tsx | 224 + .../components/Modal/ModalSelectAllFooter.tsx | 47 + .../src/shared/components/Modal/constants.ts | 5 + client/src/shared/components/Modal/index.ts | 7 + .../MorphingPlusMinus/MorphingPlusMinus.tsx | 21 + .../components/MorphingPlusMinus/index.ts | 1 + .../NamePreview/NamePreview.test.tsx | 76 + .../components/NamePreview/NamePreview.tsx | 135 + .../NamePreview/PreviewContainer.tsx | 17 + .../shared/components/NamePreview/index.ts | 4 + .../ConfiguringMiningPool.tsx | 61 + .../OnboardingSettingUp.tsx | 70 + .../components/PageOverlay/PageOverlay.tsx | 84 + .../shared/components/PageOverlay/index.ts | 3 + .../components/Picture/Picture.stories.tsx | 14 + .../src/shared/components/Picture/Picture.tsx | 30 + client/src/shared/components/Picture/index.ts | 4 + .../components/Popover/Popover.stories.tsx | 93 + .../src/shared/components/Popover/Popover.tsx | 107 + .../components/Popover/PopoverContent.tsx | 99 + .../components/Popover/PopoverContext.tsx | 41 + .../shared/components/Popover/constants.ts | 7 + client/src/shared/components/Popover/index.ts | 7 + .../src/shared/components/Popover/style.css | 33 + client/src/shared/components/Popover/types.ts | 17 + .../shared/components/Popover/usePopover.ts | 42 + .../components/Popover/usePopoverPosition.ts | 336 + .../components/PowerValue/PowerValue.tsx | 21 + .../src/shared/components/PowerValue/index.ts | 1 + .../ProgressCircular.stories.tsx | 36 + .../ProgressCircular/ProgressCircular.tsx | 60 + .../components/ProgressCircular/index.ts | 3 + .../shared/components/Radio/Radio.stories.tsx | 9 + client/src/shared/components/Radio/Radio.tsx | 39 + client/src/shared/components/Radio/index.ts | 3 + .../src/shared/components/Row/Row.stories.tsx | 41 + client/src/shared/components/Row/Row.tsx | 69 + client/src/shared/components/Row/index.ts | 3 + .../components/Search/Search.stories.tsx | 23 + .../src/shared/components/Search/Search.tsx | 80 + client/src/shared/components/Search/index.ts | 3 + .../SegmentedBarChart.stories.tsx | 406 + .../SegmentedBarChart/SegmentedBarChart.tsx | 483 + .../components/SegmentedBarChart/index.ts | 2 + .../components/SegmentedBarChart/types.ts | 39 + .../components/SegmentedBarChart/utils.ts | 56 + .../components/SegmentedControl/Segment.tsx | 35 + .../SegmentedControl.stories.tsx | 30 + .../SegmentedControl.test.tsx | 50 + .../SegmentedControl/SegmentedControl.tsx | 87 + .../components/SegmentedControl/index.ts | 3 + .../components/SegmentedControl/types.ts | 4 + .../src/shared/components/Select/Select.tsx | 208 + client/src/shared/components/Select/index.ts | 4 + .../SelectRow/SelectRow.stories.tsx | 87 + .../shared/components/SelectRow/SelectRow.tsx | 105 + .../src/shared/components/SelectRow/index.ts | 4 + .../components/SelectRowList/SelectRow.tsx | 104 + .../SelectRowList/SelectRowList.stories.tsx | 115 + .../SelectRowList/SelectRowList.tsx | 46 + .../shared/components/SelectRowList/index.ts | 3 + .../Setup/Authentication.stories.tsx | 45 + .../components/Setup/Authentication.test.tsx | 113 + .../components/Setup/Authentication.tsx | 307 + .../components/Setup/BootingUp.stories.tsx | 9 + .../src/shared/components/Setup/BootingUp.tsx | 49 + .../components/Setup/Network.stories.tsx | 26 + .../src/shared/components/Setup/Network.tsx | 95 + .../components/Setup/NetworkDetails.tsx | 48 + .../OnboardingLayout/OnboardingLayout.tsx | 55 + .../Setup/OnboardingLayout/index.ts | 3 + .../components/Setup/SetupHeader.stories.tsx | 13 + .../shared/components/Setup/SetupHeader.tsx | 15 + .../Setup/WelcomeScreen.stories.tsx | 12 + .../shared/components/Setup/WelcomeScreen.tsx | 208 + .../Setup/authentication.constants.ts | 34 + .../components/Setup/authentication.types.ts | 6 + client/src/shared/components/Setup/index.ts | 18 + .../components/Setup/miners.constants.ts | 6 + .../components/Setup/network.constants.ts | 3 + .../shared/components/Setup/network.types.ts | 3 + .../SkeletonBar/SkeletonBar.stories.tsx | 15 + .../components/SkeletonBar/SkeletonBar.tsx | 24 + .../shared/components/SkeletonBar/index.ts | 3 + .../components/SlotNumber/SlotNumber.tsx | 13 + .../src/shared/components/SlotNumber/index.ts | 1 + .../SortIndicator/SortIndicator.test.tsx | 99 + .../SortIndicator/SortIndicator.tsx | 62 + .../shared/components/SortIndicator/index.ts | 2 + .../shared/components/Stat/Stat.stories.tsx | 64 + .../src/shared/components/Stat/Stat.test.tsx | 69 + client/src/shared/components/Stat/Stat.tsx | 107 + .../src/shared/components/Stat/constants.ts | 15 + client/src/shared/components/Stat/index.ts | 5 + client/src/shared/components/Stat/types.ts | 17 + client/src/shared/components/Stats/Stats.tsx | 34 + client/src/shared/components/Stats/index.ts | 4 + .../StatusCircle/StatusCircle.stories.tsx | 30 + .../components/StatusCircle/StatusCircle.tsx | 42 + .../components/StatusCircle/constants.ts | 15 + .../shared/components/StatusCircle/index.ts | 7 + .../shared/components/StatusCircle/types.ts | 10 + .../StatusModal/ComponentMetadata.tsx | 31 + .../ComponentStatusModalContent.test.tsx | 60 + .../ComponentStatusModalContent.tsx | 102 + .../components/StatusModal/ErrorRow.tsx | 46 + .../StatusModal/MinerStatusModalContent.tsx | 93 + .../StatusModal/StatusModal.stories.tsx | 524 + .../StatusModal/StatusModal.test.tsx | 357 + .../components/StatusModal/StatusModal.tsx | 61 + .../StatusModal/StatusModalLayout.tsx | 94 + .../shared/components/StatusModal/index.ts | 16 + .../shared/components/StatusModal/types.ts | 105 + .../shared/components/StatusModal/utils.ts | 24 + .../StatusOverlay/StatusOverlay.stories.tsx | 9 + .../StatusOverlay/StatusOverlay.tsx | 54 + .../shared/components/StatusOverlay/index.ts | 3 + .../components/Switch/Switch.stories.tsx | 18 + .../src/shared/components/Switch/Switch.tsx | 54 + client/src/shared/components/Switch/index.ts | 3 + client/src/shared/components/Tab/Tab.tsx | 13 + .../shared/components/Tab/Tabs.stories.tsx | 15 + client/src/shared/components/Tab/Tabs.tsx | 99 + client/src/shared/components/Tab/index.ts | 3 + client/src/shared/components/Tab/style.css | 48 + .../components/TabMenu/Tab/Tab.test.tsx | 26 + .../src/shared/components/TabMenu/Tab/Tab.tsx | 36 + .../shared/components/TabMenu/Tab/index.ts | 3 + .../components/TabMenu/TabMenu.stories.tsx | 49 + .../components/TabMenu/TabMenu.test.tsx | 97 + .../src/shared/components/TabMenu/TabMenu.tsx | 57 + client/src/shared/components/TabMenu/index.ts | 3 + .../TemperatureValue/TemperatureValue.tsx | 29 + .../components/TemperatureValue/index.ts | 1 + .../components/Textarea/Textarea.stories.tsx | 100 + .../components/Textarea/Textarea.test.tsx | 86 + .../shared/components/Textarea/Textarea.tsx | 229 + .../src/shared/components/Textarea/index.ts | 3 + .../components/Tooltip/Tooltip.stories.tsx | 36 + .../src/shared/components/Tooltip/Tooltip.tsx | 42 + client/src/shared/components/Tooltip/index.ts | 3 + .../components/VoltageValue/VoltageValue.tsx | 9 + .../shared/components/VoltageValue/index.ts | 1 + client/src/shared/constants/breakpoints.ts | 8 + client/src/shared/constants/cooling.ts | 6 + client/src/shared/constants/index.ts | 19 + client/src/shared/constants/statuses.ts | 6 + .../TemperatureUnitsSwitcher.stories.tsx | 20 + .../TemperatureUnitsSwitcher.test.tsx | 55 + .../preferences/TemperatureUnitsSwitcher.tsx | 74 + .../preferences/ThemeSwitcher.stories.tsx | 14 + .../preferences/ThemeSwitcher.test.tsx | 57 + .../features/preferences/ThemeSwitcher.tsx | 96 + .../src/shared/features/preferences/index.ts | 7 + .../src/shared/features/preferences/types.ts | 5 + .../features/preferences/useApplyTheme.ts | 45 + .../shared/features/toaster/ToastsObserver.ts | 90 + .../GroupedToaster/GroupedToast.tsx | 62 + .../GroupedToaster/GroupedToaster.stories.tsx | 45 + .../GroupedToaster/GroupedToaster.test.tsx | 230 + .../GroupedToaster/GroupedToaster.tsx | 175 + .../components/GroupedToaster/index.ts | 3 + .../components/Toast/Toast.stories.tsx | 27 + .../toaster/components/Toast/Toast.tsx | 89 + .../toaster/components/Toast/index.ts | 3 + .../components/Toaster/Toaster.stories.tsx | 111 + .../toaster/components/Toaster/Toaster.tsx | 75 + .../toaster/components/Toaster/index.ts | 3 + .../src/shared/features/toaster/constants.ts | 15 + client/src/shared/features/toaster/index.ts | 21 + client/src/shared/features/toaster/types.ts | 29 + client/src/shared/hooks/useClickOutside.ts | 53 + client/src/shared/hooks/useCssVariable.ts | 77 + .../shared/hooks/useFloatingPosition.test.ts | 176 + .../src/shared/hooks/useFloatingPosition.ts | 153 + client/src/shared/hooks/useKeyDown.ts | 28 + client/src/shared/hooks/useLocalStorage.ts | 45 + client/src/shared/hooks/useMeasure.ts | 224 + client/src/shared/hooks/useNavigate.ts | 19 + .../shared/hooks/useNeedsAttention.test.ts | 194 + client/src/shared/hooks/useNeedsAttention.ts | 25 + client/src/shared/hooks/usePoll.ts | 67 + client/src/shared/hooks/usePreventScroll.ts | 45 + .../shared/hooks/useReactiveLocalStorage.ts | 65 + .../src/shared/hooks/useSlideUpAnimation.ts | 19 + .../shared/hooks/useStatusSummary/index.ts | 15 + .../shared/hooks/useStatusSummary/types.ts | 55 + .../useStatusSummary/useStatusSummary.test.ts | 547 + .../useStatusSummary/useStatusSummary.ts | 257 + .../shared/hooks/useStatusSummary/utils.ts | 103 + client/src/shared/hooks/useStickyState.ts | 85 + .../src/shared/hooks/useWindowDimensions.ts | 50 + client/src/shared/stories/colors.stories.tsx | 218 + .../stories/createRefCountedStoryMock.ts | 23 + .../src/shared/stories/elevation.stories.tsx | 25 + client/src/shared/stories/icons.tsx | 27 + .../src/shared/stories/typography.stories.tsx | 35 + client/src/shared/styles/fonts.css | 26 + client/src/shared/styles/index.css | 3 + client/src/shared/styles/theme.css | 450 + client/src/shared/styles/theme.test.ts | 85 + client/src/shared/utils/backendHealth.test.ts | 52 + client/src/shared/utils/backendHealth.ts | 16 + client/src/shared/utils/cssUtils.test.ts | 32 + client/src/shared/utils/cssUtils.ts | 14 + client/src/shared/utils/datetime.test.ts | 69 + client/src/shared/utils/datetime.ts | 61 + .../shared/utils/fleetDownRedirect.test.ts | 116 + client/src/shared/utils/fleetDownRedirect.ts | 19 + client/src/shared/utils/formatTimestamp.ts | 44 + client/src/shared/utils/math.ts | 53 + client/src/shared/utils/measurementUtils.ts | 22 + client/src/shared/utils/network.test.ts | 28 + client/src/shared/utils/network.ts | 12 + .../src/shared/utils/networkDiscovery.test.ts | 139 + client/src/shared/utils/networkDiscovery.ts | 169 + client/src/shared/utils/object.test.ts | 33 + client/src/shared/utils/object.ts | 31 + client/src/shared/utils/predicate.test.ts | 84 + client/src/shared/utils/predicate.ts | 7 + client/src/shared/utils/routeUtils.ts | 0 client/src/shared/utils/stringUtils.test.ts | 182 + client/src/shared/utils/stringUtils.ts | 55 + client/src/shared/utils/utility.test.ts | 253 + client/src/shared/utils/utility.ts | 161 + client/src/shared/utils/version.ts | 30 + client/src/tests/setup.ts | 31 + client/src/vite-env.d.ts | 1 + client/tsconfig.json | 39 + client/tsconfig.node.json | 11 + client/vite.config.ts | 168 + client/vitePlugins/responsiveImagePlugin.ts | 53 + deployment-files/README.md | 85 + deployment-files/client/Dockerfile | 15 + deployment-files/client/nginx.http.conf | 21 + deployment-files/client/nginx.https.conf | 42 + ...compose.protofleet-real-miners-runner.yaml | 101 + deployment-files/docker-compose.yaml | 83 + deployment-files/install.sh | 214 + deployment-files/migrate-data.sh | 548 + deployment-files/rollback-migration.sh | 181 + deployment-files/run-fleet.sh | 626 + deployment-files/scripts/export-influxdb.sh | 476 + deployment-files/scripts/export-mysql.sh | 222 + deployment-files/scripts/import-postgres.sh | 289 + .../scripts/import-timescaledb.sh | 310 + deployment-files/scripts/lib.sh | 282 + deployment-files/server/Dockerfile | 16 + deployment-files/uninstall.sh | 366 + deployment-files/windows/.gitignore | 20 + deployment-files/windows/NuGet.Config | 7 + .../windows/PSScriptAnalyzerSettings.psd1 | 5 + .../windows/ProtoFleet.Installer.sln | 45 + deployment-files/windows/README.md | 248 + .../windows/build-fleet-installer.ps1 | 94 + .../windows/build-fleet-uninstaller-exe.ps1 | 46 + .../windows/fleet-uninstaller.ps1 | 1075 ++ deployment-files/windows/global.json | 6 + .../src/ProtoFleet.Installer.App/App.xaml | 16 + .../src/ProtoFleet.Installer.App/App.xaml.cs | 78 + .../InstallerApplicationService.cs | 96 + .../LinuxUserSetupCoordinator.cs | 278 + .../ProtoFleet.Installer.App/MainWindow.xaml | 350 + .../MainWindow.xaml.cs | 840 ++ .../ProtoFleet.Installer.App.csproj | 26 + .../src/ProtoFleet.Installer.App/UiLogSink.cs | 19 + .../src/ProtoFleet.Installer.App/app.manifest | 11 + .../ProtoFleet.Installer.Core/Abstractions.cs | 96 + .../CommandEscaping.cs | 29 + .../CommandRequest.cs | 20 + .../CommandResult.cs | 14 + .../DeploymentResolution.cs | 12 + .../src/ProtoFleet.Installer.Core/EnvFile.cs | 118 + .../HostCheckReport.cs | 22 + .../InstallerCheckpoint.cs | 10 + .../InstallerContext.cs | 32 + .../InstallerExitCode.cs | 10 + .../InstallerOptions.cs | 28 + .../InstallerOptionsParser.cs | 102 + .../InstallerOptionsParserException.cs | 9 + .../InstallerOrchestrator.cs | 26 + .../InstallerResumeState.cs | 12 + .../InstallerRunResult.cs | 24 + .../InstallerStepResult.cs | 35 + .../InstallerUserActionType.cs | 7 + .../InstallerWorkflowRunner.cs | 114 + .../src/ProtoFleet.Installer.Core/LogSinks.cs | 81 + .../ProcessCommandRunner.cs | 103 + .../ProtoFleet.Installer.Core.csproj | 8 + .../ProtoFleet.Installer.Core/ProtocolMode.cs | 9 + .../ProtoFleet.Installer.Core/RetryPolicy.cs | 34 + .../ScheduledTaskCommandBuilder.cs | 10 + .../Services/DeploymentResolver.cs | 176 + .../Services/EnvConfigurator.cs | 82 + .../Services/NginxConfigurator.cs | 128 + .../ProtoFleet.Installer.Core/SetupMode.cs | 7 + .../ShellEscaping.cs | 9 + .../Steps/ConfigureEnvironmentStep.cs | 18 + .../Steps/ConfigureNginxStep.cs | 18 + .../Steps/DeployComposeStep.cs | 18 + .../Steps/PostStartHealthStep.cs | 18 + .../Steps/PreflightAndSetupStep.cs | 75 + .../Steps/ResolveDeploymentStep.cs | 49 + .../Steps/ScheduledTaskStep.cs | 23 + .../Steps/ValidatePluginsStep.cs | 18 + .../WindowsFeatureCheck.cs | 12 + .../JsonFileResumeStateStore.cs | 68 + ...otoFleet.Installer.Platform.Windows.csproj | 11 + .../RunOnceResumeRegistrationService.cs | 34 + .../ScheduledTaskService.cs | 53 + .../SystemPrereqService.cs | 318 + .../WindowsElevationService.cs | 40 + .../ComposeDeployer.cs | 255 + .../DeploymentPreparationService.cs | 241 + .../DockerReadinessService.cs | 397 + .../PluginValidator.cs | 50 + .../PostStartHealthChecker.cs | 45 + .../ProtoFleet.Installer.Platform.Wsl.csproj | 11 + .../WslCommandExecutor.cs | 239 + .../WslOutputClassifier.cs | 49 + .../WslRecoveryService.cs | 51 + .../WslSetupService.cs | 733 + .../CommandEscapingTests.cs | 33 + .../DeploymentResolverTests.cs | 122 + .../EnvFileTests.cs | 30 + .../InstallerOptionsParserTests.cs | 64 + ...nstallerWorkflowRunnerCancellationTests.cs | 48 + .../InstallerWorkflowRunnerResumeTests.cs | 87 + .../ProtoFleet.Installer.Tests.csproj | 20 + .../windows/wsl-keepalive-trials.md | 20 + dev.sh | 41 + docs/architecture.md | 54 + docs/logo.svg | 8 + go.work | 9 + go.work.sum | 655 + hermit-packages/proto-python-gen.hcl | 15 + just/go-plugin.just | 34 + justfile | 282 + lefthook.yml | 54 + packages/proto-python-gen/README.md | 96 + .../bin/protoc-gen-python-grpc | 49 + .../proto-python-gen/dev-requirements.txt | 1 + packages/proto-python-gen/justfile | 175 + .../proto-python-gen-0.2.0.tar.gz | Bin 0 -> 3994 bytes .../protoc_gen_python_grpc.py | 145 + packages/proto-python-gen/requirements.txt | 2 + packages/proto-python-gen/setup.sh | 83 + .../tests/test_protos/buf.gen.prefix.yaml | 10 + .../tests/test_protos/buf.gen.yaml | 8 + .../tests/test_protos/buf.yaml | 3 + .../tests/test_protos/test/v1/test.proto | 38 + plugin/antminer/.golangci.yaml | 132 + plugin/antminer/README.md | 71 + plugin/antminer/go.mod | 34 + plugin/antminer/go.sum | 96 + plugin/antminer/internal/device/device.go | 624 + .../antminer/internal/device/device_test.go | 942 ++ plugin/antminer/internal/device/errors.go | 825 ++ .../antminer/internal/device/errors_test.go | 1229 ++ plugin/antminer/internal/driver/driver.go | 407 + .../antminer/internal/driver/driver_test.go | 611 + plugin/antminer/internal/types/types.go | 36 + plugin/antminer/justfile | 20 + plugin/antminer/main.go | 30 + plugin/antminer/pkg/antminer/client.go | 832 ++ plugin/antminer/pkg/antminer/client_test.go | 900 ++ plugin/antminer/pkg/antminer/interface.go | 52 + .../pkg/antminer/mocks/mock_client.go | 363 + .../pkg/antminer/networking/networking.go | 98 + .../pkg/antminer/rpc/mocks/mock_rpc_client.go | 133 + plugin/antminer/pkg/antminer/rpc/service.go | 147 + .../antminer/pkg/antminer/rpc/service_test.go | 544 + plugin/antminer/pkg/antminer/rpc/types.go | 179 + plugin/antminer/pkg/antminer/web/config.go | 18 + plugin/antminer/pkg/antminer/web/digest.go | 24 + .../antminer/web/mocks/mock_web_api_client.go | 217 + plugin/antminer/pkg/antminer/web/service.go | 748 + .../antminer/pkg/antminer/web/service_test.go | 812 ++ plugin/antminer/pkg/antminer/web/type.go | 5 + plugin/asicrs/Cargo.lock | 3478 +++++ plugin/asicrs/Cargo.toml | 33 + plugin/asicrs/Dockerfile.build | 41 + plugin/asicrs/config.yaml | 41 + plugin/asicrs/src/capabilities.rs | 448 + plugin/asicrs/src/config.rs | 91 + plugin/asicrs/src/device.rs | 1270 ++ plugin/asicrs/src/driver.rs | 1093 ++ plugin/asicrs/src/main.rs | 33 + plugin/example-python/.gitignore | 12 + plugin/example-python/Dockerfile.build | 35 + .../example-python/example_driver/__init__.py | 0 .../example-python/example_driver/driver.py | 164 + plugin/example-python/main.py | 28 + plugin/example-python/pyproject.toml | 42 + plugin/example-python/tests/__init__.py | 0 plugin/example-python/tests/test_driver.py | 120 + plugin/proto/.golangci.yaml | 132 + plugin/proto/README.md | 338 + plugin/proto/docs/getting-started.md | 339 + plugin/proto/docs/integration-testing.md | 366 + plugin/proto/docs/sdk-patterns.md | 486 + plugin/proto/go.mod | 81 + plugin/proto/go.sum | 198 + plugin/proto/internal/device/device.go | 753 + plugin/proto/internal/device/device_test.go | 215 + plugin/proto/internal/device/errors.go | 620 + plugin/proto/internal/device/errors_test.go | 753 + plugin/proto/internal/device/types/types.go | 13 + .../proto/internal/driver/discovery_test.go | 571 + plugin/proto/internal/driver/driver.go | 370 + plugin/proto/justfile | 24 + plugin/proto/main.go | 47 + plugin/proto/pkg/proto/client.go | 1237 ++ plugin/proto/pkg/proto/client_test.go | 1221 ++ plugin/proto/pkg/proto/errors.go | 27 + plugin/proto/tests/integration_test.go | 622 + plugin/proto/tests/testutils/jwt.go | 68 + plugin/proto/tests/unit/plugin_test.go | 87 + plugin/virtual/README.md | 132 + plugin/virtual/config.json | 76 + plugin/virtual/go.mod | 26 + plugin/virtual/go.sum | 80 + plugin/virtual/internal/config/config.go | 420 + plugin/virtual/internal/device/device.go | 254 + plugin/virtual/internal/driver/driver.go | 203 + plugin/virtual/main.go | 44 + plugin/virtual/pkg/virtual/simulator.go | 224 + proto-rig-api/README.md | 51 + proto-rig-api/VERSION.md | 52 + proto-rig-api/grpc/hashboard.proto | 35 + proto-rig-api/grpc/hashboard_async.proto | 157 + proto-rig-api/grpc/hashboard_cmd.proto | 434 + proto-rig-api/grpc/hashboard_cmd_debug.proto | 113 + .../grpc/hashboard_cmd_mfgtest.proto | 80 + proto-rig-api/grpc/hashboard_log.proto | 46 + proto-rig-api/grpc/mfgtool_api.proto | 162 + .../grpc/mfgtool_test_commands.proto | 147 + proto-rig-api/grpc/miner_command_api.proto | 75 + proto-rig-api/grpc/miner_common_api.proto | 127 + proto-rig-api/grpc/miner_data_api.proto | 682 + proto-rig-api/grpc/miner_debug_api.proto | 334 + proto-rig-api/grpc/miner_error_code.proto | 91 + proto-rig-api/grpc/miner_fan_api.proto | 35 + proto-rig-api/grpc/miner_hb_api.proto | 276 + proto-rig-api/grpc/miner_psu_api.proto | 281 + proto-rig-api/grpc/miner_psu_test_api.proto | 50 + proto-rig-api/grpc/miner_system_api.proto | 137 + proto-rig-api/grpc/miner_ui_api.proto | 174 + proto-rig-api/openapi/MDK-API.json | 5828 ++++++++ proto/activity/v1/activity.proto | 158 + proto/apikey/v1/apikey.proto | 71 + proto/auth/v1/auth.proto | 195 + proto/capabilities/v1/capabilities.proto | 110 + proto/collection/v1/collection.proto | 554 + proto/common/v1/common.proto | 15 + proto/common/v1/cooling.proto | 11 + proto/common/v1/device_selector.proto | 20 + proto/common/v1/measurement.proto | 44 + proto/common/v1/sort.proto | 59 + proto/device_set/v1/device_set.proto | 553 + proto/errors/v1/errors.proto | 299 + .../fleetmanagement/v1/fleetmanagement.proto | 567 + .../v1/fleetperformance.proto | 206 + proto/foremanimport/v1/foremanimport.proto | 59 + proto/minercommand/v1/command.proto | 289 + proto/networkinfo/v1/networkinfo.proto | 56 + proto/onboarding/v1/onboarding.proto | 43 + proto/pairing/v1/pairing.proto | 149 + proto/ping/v1/ping.proto | 42 + proto/pools/v1/pools.proto | 159 + proto/schedule/v1/schedule.proto | 373 + proto/telemetry/v1/telemetry.proto | 284 + scripts/lefthook-client-format.sh | 50 + scripts/lefthook-goimports.sh | 20 + scripts/lefthook-lib.sh | 38 + scripts/lefthook-proto-buf-lint.sh | 49 + scripts/lefthook-python-ruff.sh | 115 + scripts/pip-config.sh | 23 + sdk/rust/proto-fleet-plugin/Cargo.lock | 1994 +++ sdk/rust/proto-fleet-plugin/Cargo.toml | 22 + sdk/rust/proto-fleet-plugin/build.rs | 22 + .../proto-fleet-plugin/src/capabilities.rs | 86 + sdk/rust/proto-fleet-plugin/src/errors.rs | 53 + .../proto-fleet-plugin/src/http_client.rs | 217 + sdk/rust/proto-fleet-plugin/src/lib.rs | 59 + sdk/rust/proto-fleet-plugin/src/pb.rs | 3 + server/.golangci.yaml | 136 + server/Dockerfile | 49 + server/README.md | 222 + server/buf.gen.yaml | 28 + server/buf.yaml | 11 + server/cmd/fleetd/config.go | 46 + server/cmd/fleetd/main.go | 431 + server/devtools/seedtelemetry/README.md | 99 + server/devtools/seedtelemetry/config.go | 46 + server/devtools/seedtelemetry/generator.go | 233 + server/devtools/seedtelemetry/inserter.go | 182 + server/devtools/seedtelemetry/main.go | 164 + server/docker-compose-README.md | 77 + server/docker-compose.base.yaml | 90 + server/docker-compose.yaml | 318 + server/docs/queries.md | 162 + server/e2e/README.md | 621 + server/e2e/plugin_integration_test.go | 534 + server/embed.go | 6 + server/fake-antminer/Dockerfile | 42 + server/fake-antminer/README.md | 92 + server/fake-antminer/docker-entrypoint.sh | 20 + server/fake-antminer/go.mod | 5 + server/fake-antminer/go.sum | 2 + server/fake-antminer/http_handlers.go | 614 + server/fake-antminer/main.go | 296 + server/fake-antminer/models.go | 240 + server/fake-antminer/rpc_handlers.go | 364 + server/fake-antminer/sleep_mode_test.go | 177 + server/fake-proto-rig/Dockerfile | 33 + server/fake-proto-rig/README.md | 201 + server/fake-proto-rig/go.mod | 5 + server/fake-proto-rig/go.sum | 2 + server/fake-proto-rig/main.go | 243 + server/fake-proto-rig/models.go | 569 + server/fake-proto-rig/rest_api_handler.go | 2961 ++++ .../fake-proto-rig/rest_api_handler_test.go | 1843 +++ .../generated/grpc/activity/v1/activity.pb.go | 950 ++ .../v1/activityv1connect/activity.connect.go | 166 + server/generated/grpc/apikey/v1/apikey.pb.go | 541 + .../v1/apikeyv1connect/apikey.connect.go | 168 + server/generated/grpc/auth/v1/auth.pb.go | 1488 ++ .../auth/v1/authv1connect/auth.connect.go | 380 + .../grpc/buf/validate/validate.pb.go | 11081 +++++++++++++++ .../grpc/capabilities/v1/capabilities.pb.go | 785 ++ .../grpc/collection/v1/collection.pb.go | 3680 +++++ .../collectionv1connect/collection.connect.go | 547 + server/generated/grpc/common/v1/common.pb.go | 252 + server/generated/grpc/common/v1/cooling.pb.go | 151 + .../grpc/common/v1/device_selector.pb.go | 242 + .../grpc/common/v1/measurement.pb.go | 264 + server/generated/grpc/common/v1/sort.pb.go | 346 + .../grpc/device_set/v1/device_set.pb.go | 3672 +++++ .../device_setv1connect/device_set.connect.go | 545 + server/generated/grpc/errors/v1/errors.pb.go | 2434 ++++ .../v1/errorsv1connect/errors.connect.go | 193 + .../fleetmanagement/v1/fleetmanagement.pb.go | 3228 +++++ .../fleetmanagement.connect.go | 374 + .../v1/fleetperformance.pb.go | 1393 ++ .../fleetperformance.connect.go | 176 + .../grpc/foremanimport/v1/foremanimport.pb.go | 549 + .../foremanimport.connect.go | 142 + .../grpc/minercommand/v1/command.pb.go | 2736 ++++ .../minercommandv1connect/command.connect.go | 480 + .../grpc/networkinfo/v1/networkinfo.pb.go | 403 + .../networkinfo.connect.go | 137 + .../grpc/onboarding/v1/onboarding.pb.go | 516 + .../onboardingv1connect/onboarding.connect.go | 160 + .../generated/grpc/pairing/v1/pairing.pb.go | 937 ++ .../v1/pairingv1connect/pairing.connect.go | 137 + server/generated/grpc/ping/v1/ping.pb.go | 391 + .../ping/v1/pingv1connect/ping.connect.go | 167 + server/generated/grpc/pools/v1/pools.pb.go | 945 ++ .../pools/v1/poolsv1connect/pools.connect.go | 220 + .../generated/grpc/schedule/v1/schedule.pb.go | 2059 +++ .../v1/schedulev1connect/schedule.connect.go | 282 + .../grpc/telemetry/v1/telemetry.pb.go | 2213 +++ .../telemetryv1connect/telemetry.connect.go | 137 + .../miner-api/hashboard/hashboard.pb.go | 227 + .../hashboard_async/hashboard_async.pb.go | 963 ++ .../hashboard_cmd/hashboard_cmd.pb.go | 4740 +++++++ .../hashboard_cmd_debug.pb.go | 1337 ++ .../hashboard_cmd_mfgtest.pb.go | 1205 ++ server/generated/sqlc/activity.sql.go | 316 + server/generated/sqlc/api_key.sql.go | 183 + server/generated/sqlc/command.sql.go | 201 + server/generated/sqlc/db.go | 2158 +++ server/generated/sqlc/device.sql.go | 1786 +++ server/generated/sqlc/device_set.sql.go | 1012 ++ .../generated/sqlc/discovered_device.sql.go | 350 + server/generated/sqlc/errors.sql.go | 923 ++ .../generated/sqlc/miner_credentials.sql.go | 67 + server/generated/sqlc/miner_service.sql.go | 129 + server/generated/sqlc/models.go | 707 + server/generated/sqlc/organization.sql.go | 194 + server/generated/sqlc/pool.sql.go | 187 + server/generated/sqlc/queue.sql.go | 318 + server/generated/sqlc/role.sql.go | 149 + server/generated/sqlc/schedule.sql.go | 781 ++ server/generated/sqlc/session.sql.go | 122 + server/generated/sqlc/telemetry.sql.go | 993 ++ server/generated/sqlc/user.sql.go | 322 + .../generated/sqlc/user_organization.sql.go | 188 + server/go.mod | 74 + server/go.sum | 228 + server/http-client.env.json | 20 + .../internal/domain/activity/models/models.go | 131 + server/internal/domain/activity/service.go | 107 + .../internal/domain/activity/service_test.go | 346 + server/internal/domain/apikey/service.go | 235 + server/internal/domain/apikey/service_test.go | 86 + server/internal/domain/auth/password.go | 35 + server/internal/domain/auth/service.go | 850 ++ server/internal/domain/auth/service_test.go | 867 ++ server/internal/domain/collection/service.go | 1092 ++ .../domain/collection/service_test.go | 1402 ++ .../domain/command/capability_checker.go | 286 + .../domain/command/capability_checker_test.go | 495 + .../domain/command/capability_mapping.go | 34 + server/internal/domain/command/config.go | 15 + .../domain/command/execution_service.go | 1051 ++ .../execution_service_credentials_test.go | 246 + .../domain/command/execution_service_test.go | 1186 ++ .../command/mark_status_integration_test.go | 305 + .../domain/command/mocks/mock_miner_getter.go | 109 + .../domain/command/reaper_integration_test.go | 269 + server/internal/domain/command/service.go | 910 ++ .../service_update_mining_pools_test.go | 74 + .../domain/command/service_user_auth_test.go | 511 + .../internal/domain/command/status_service.go | 125 + .../domain/command/status_service_test.go | 490 + server/internal/domain/commandtype/enum.go | 101 + .../domain/deviceresolver/resolver.go | 78 + .../domain/deviceresolver/resolver_test.go | 179 + server/internal/domain/diagnostics/closer.go | 49 + .../domain/diagnostics/closer_test.go | 165 + .../component_type_integration_test.go | 254 + server/internal/domain/diagnostics/config.go | 30 + .../internal/domain/diagnostics/grouping.go | 278 + .../domain/diagnostics/grouping_test.go | 574 + .../domain/diagnostics/models/miner_errors.go | 556 + .../diagnostics/models/miner_errors_test.go | 278 + .../domain/diagnostics/models/pagination.go | 112 + .../domain/diagnostics/models/query.go | 141 + .../internal/domain/diagnostics/pagination.go | 153 + .../domain/diagnostics/pagination_test.go | 235 + server/internal/domain/diagnostics/service.go | 413 + .../domain/diagnostics/service_test.go | 734 + server/internal/domain/diagnostics/watcher.go | 195 + .../domain/diagnostics/watcher_test.go | 795 ++ server/internal/domain/fleeterror/error.go | 437 + .../internal/domain/fleeterror/error_test.go | 325 + .../internal/domain/fleeterror/stacktrace.go | 41 + .../internal/domain/fleetimport/importer.go | 443 + .../fleetimport/importer_worker_name_test.go | 72 + server/internal/domain/fleetimport/types.go | 77 + .../domain/fleetmanagement/collection_test.go | 229 + .../domain/fleetmanagement/export_csv.go | 454 + .../export_csv_service_test.go | 99 + .../domain/fleetmanagement/export_csv_test.go | 486 + .../domain/fleetmanagement/models/models.go | 15 + .../fleetmanagement/pool_matching_test.go | 237 + .../domain/fleetmanagement/renaming.go | 797 ++ .../domain/fleetmanagement/renaming_test.go | 1106 ++ .../domain/fleetmanagement/service.go | 925 ++ .../domain/fleetmanagement/service_test.go | 1852 +++ .../internal/domain/fleetmanagement/sort.go | 46 + .../domain/fleetmanagement/sort_test.go | 138 + .../domain/fleetmanagement/telemetry.go | 31 + .../internal/domain/foremanimport/mapper.go | 156 + .../domain/foremanimport/mapper_test.go | 95 + .../internal/domain/foremanimport/service.go | 210 + .../domain/foremanimport/service_test.go | 261 + server/internal/domain/ipscanner/config.go | 19 + .../domain/ipscanner/integration_test.go | 197 + .../domain/ipscanner/mocks/mock_scanner.go | 56 + server/internal/domain/ipscanner/models.go | 34 + .../domain/ipscanner/network_utils.go | 118 + .../domain/ipscanner/network_utils_test.go | 404 + server/internal/domain/ipscanner/scanner.go | 187 + server/internal/domain/ipscanner/service.go | 406 + .../internal/domain/ipscanner/service_test.go | 182 + server/internal/domain/miner/cache_test.go | 216 + .../internal/domain/miner/dto/command_dto.go | 35 + server/internal/domain/miner/dto/firmware.go | 7 + .../internal/domain/miner/interfaces/miner.go | 77 + .../miner/interfaces/mocks/mock_miner.go | 510 + server/internal/domain/miner/models/device.go | 19 + .../domain/miner/models/device_test.go | 40 + server/internal/domain/miner/models/enums.go | 68 + server/internal/domain/miner/service.go | 187 + server/internal/domain/miner/service_test.go | 295 + .../internal/domain/miner/testhelpers_test.go | 199 + .../minerdiscovery/mocks/mock_discoverer.go | 51 + .../domain/minerdiscovery/models/models.go | 37 + .../internal/domain/minerdiscovery/service.go | 16 + server/internal/domain/onboarding/service.go | 62 + .../domain/pairing/mocks/mock_pairer.go | 66 + .../domain/pairing/mocks/mock_service.go | 168 + server/internal/domain/pairing/pairing.go | 25 + server/internal/domain/pairing/service.go | 1611 +++ .../domain/pairing/service_internal_test.go | 497 + .../internal/domain/pairing/service_test.go | 2320 ++++ .../internal/domain/plugins/capabilities.go | 96 + .../domain/plugins/capabilities_test.go | 276 + server/internal/domain/plugins/config.go | 50 + server/internal/domain/plugins/config_test.go | 308 + server/internal/domain/plugins/discoverer.go | 171 + .../domain/plugins/discoverer_test.go | 444 + server/internal/domain/plugins/manager.go | 411 + .../internal/domain/plugins/manager_test.go | 221 + .../plugins/mappers/miner_error_mapper.go | 113 + .../mappers/miner_error_mapper_test.go | 271 + .../domain/plugins/mappers/sdk_mapper.go | 283 + .../domain/plugins/mappers/sdk_mapper_test.go | 699 + server/internal/domain/plugins/pairer.go | 709 + server/internal/domain/plugins/pairer_test.go | 1734 +++ .../internal/domain/plugins/plugin_factory.go | 197 + .../domain/plugins/plugin_factory_test.go | 56 + .../internal/domain/plugins/plugin_miner.go | 658 + .../domain/plugins/plugin_miner_test.go | 1418 ++ server/internal/domain/plugins/service.go | 227 + .../internal/domain/plugins/service_test.go | 611 + server/internal/domain/pools/config.go | 7 + server/internal/domain/pools/service.go | 206 + server/internal/domain/pools/service_test.go | 228 + server/internal/domain/pools/validation.go | 17 + server/internal/domain/schedule/cron.go | 196 + server/internal/domain/schedule/cron_test.go | 377 + .../schedule/mock_command_dispatcher_test.go | 87 + server/internal/domain/schedule/processor.go | 805 ++ .../domain/schedule/processor_test.go | 816 ++ server/internal/domain/schedule/service.go | 652 + .../internal/domain/schedule/service_test.go | 338 + server/internal/domain/session/config.go | 20 + server/internal/domain/session/context.go | 20 + .../session/mocks/mock_session_store.go | 129 + server/internal/domain/session/models.go | 57 + server/internal/domain/session/service.go | 170 + .../domain/stores/interfaces/activity.go | 18 + .../domain/stores/interfaces/apikey.go | 31 + .../domain/stores/interfaces/collection.go | 110 + .../domain/stores/interfaces/device.go | 138 + .../stores/interfaces/discovered_device.go | 35 + .../domain/stores/interfaces/error.go | 67 + .../interfaces/mocks/mock_activity_store.go | 131 + .../interfaces/mocks/mock_collection_store.go | 416 + .../interfaces/mocks/mock_device_store.go | 506 + .../mocks/mock_discovered_device_store.go | 147 + .../interfaces/mocks/mock_error_store.go | 178 + .../interfaces/mocks/mock_schedule_store.go | 424 + .../interfaces/mocks/mock_transactor.go | 70 + .../interfaces/mocks/mock_user_store.go | 359 + .../internal/domain/stores/interfaces/pool.go | 16 + .../domain/stores/interfaces/schedule.go | 62 + .../internal/domain/stores/interfaces/sort.go | 99 + .../domain/stores/interfaces/sort_test.go | 172 + .../domain/stores/interfaces/transactor.go | 17 + .../internal/domain/stores/interfaces/user.go | 64 + .../domain/stores/sqlstores/activity.go | 292 + .../domain/stores/sqlstores/activity_test.go | 41 + .../domain/stores/sqlstores/apikey.go | 117 + .../domain/stores/sqlstores/collection.go | 760 ++ .../stores/sqlstores/collection_cursor.go | 89 + .../sqlstores/collection_integration_test.go | 774 ++ .../stores/sqlstores/collection_sort.go | 280 + .../stores/sqlstores/collection_sort_test.go | 180 + .../domain/stores/sqlstores/device.go | 1435 ++ .../domain/stores/sqlstores/device_cursor.go | 61 + .../stores/sqlstores/device_cursor_test.go | 217 + .../domain/stores/sqlstores/device_filters.go | 203 + .../stores/sqlstores/device_filters_test.go | 405 + .../sqlstores/device_integration_test.go | 2304 ++++ .../sqlstores/device_query_fragments.go | 158 + .../domain/stores/sqlstores/device_sort.go | 138 + .../stores/sqlstores/device_sort_test.go | 316 + .../domain/stores/sqlstores/device_test.go | 118 + .../stores/sqlstores/discovered_device.go | 241 + .../sqlstores/discovered_device_test.go | 591 + .../internal/domain/stores/sqlstores/error.go | 973 ++ .../sqlstores/error_integration_test.go | 1146 ++ .../internal/domain/stores/sqlstores/pool.go | 144 + .../domain/stores/sqlstores/schedule.go | 658 + .../domain/stores/sqlstores/session.go | 116 + .../sqlstores/sql_connection_manager.go | 39 + .../domain/stores/sqlstores/transactor.go | 47 + .../stores/sqlstores/transactor_test.go | 157 + .../internal/domain/stores/sqlstores/user.go | 255 + .../internal/domain/telemetry/broadcaster.go | 384 + server/internal/domain/telemetry/config.go | 16 + .../internal/domain/telemetry/interfaces.go | 15 + .../domain/telemetry/mocks/mock_interfaces.go | 62 + .../domain/telemetry/mocks/mock_service.go | 359 + .../domain/telemetry/models/data_models.go | 29 + .../domain/telemetry/models/device.go | 25 + .../internal/domain/telemetry/models/enums.go | 210 + .../domain/telemetry/models/queries.go | 79 + .../internal/domain/telemetry/models/units.go | 29 + .../domain/telemetry/models/units_test.go | 101 + .../telemetry/models/v2/component_metrics.go | 92 + .../telemetry/models/v2/device_metrics.go | 90 + .../models/v2/device_metrics_test.go | 164 + .../domain/telemetry/models/v2/enums.go | 321 + .../telemetry/models/v2/metric_value.go | 34 + .../domain/telemetry/scheduler/config.go | 5 + .../domain/telemetry/scheduler/errors.go | 39 + .../domain/telemetry/scheduler/scheduler.go | 204 + .../telemetry/scheduler/scheduler_test.go | 1082 ++ server/internal/domain/telemetry/service.go | 1161 ++ .../internal/domain/telemetry/service_test.go | 2693 ++++ .../domain/telemetry/temperature_status.go | 12 + server/internal/domain/token/config.go | 15 + server/internal/domain/token/service.go | 140 + server/internal/domain/token/service_test.go | 137 + .../internal/domain/workername/workername.go | 46 + .../domain/workername/workername_test.go | 23 + server/internal/handlers/activity/handler.go | 382 + .../handlers/activity/handler_test.go | 608 + server/internal/handlers/apikey/handler.go | 123 + .../apikey/handler_error_sanitization_test.go | 173 + .../internal/handlers/apikey/handler_test.go | 252 + server/internal/handlers/auth/handler.go | 157 + server/internal/handlers/auth/handler_test.go | 272 + .../internal/handlers/collection/handler.go | 168 + server/internal/handlers/command/handler.go | 213 + .../internal/handlers/command/handler_test.go | 10 + server/internal/handlers/deviceset/convert.go | 241 + server/internal/handlers/deviceset/handler.go | 248 + .../handlers/errorquery/conversions.go | 302 + .../handlers/errorquery/conversions_test.go | 394 + .../internal/handlers/errorquery/handler.go | 156 + .../handlers/errorquery/handler_test.go | 236 + server/internal/handlers/firmware/chunked.go | 394 + .../handlers/firmware/chunked_test.go | 343 + server/internal/handlers/firmware/handler.go | 449 + .../handlers/firmware/handler_test.go | 688 + .../handlers/fleetmanagement/handler.go | 101 + .../handlers/fleetmanagement/handler_test.go | 91 + .../handlers/foremanimport/handler.go | 54 + server/internal/handlers/health/handler.go | 25 + .../handlers/interceptors/authentication.go | 236 + .../authentication_apikey_error_test.go | 79 + .../interceptors/authentication_test.go | 457 + .../internal/handlers/interceptors/config.go | 71 + .../handlers/interceptors/config_test.go | 16 + .../handlers/interceptors/error_mapping.go | 53 + .../interceptors/error_stack_trace_logging.go | 72 + .../handlers/interceptors/request_logging.go | 244 + server/internal/handlers/middleware/cors.go | 35 + .../internal/handlers/networkinfo/handler.go | 43 + .../internal/handlers/onboarding/handler.go | 58 + .../handlers/onboarding/handler_test.go | 121 + server/internal/handlers/pairing/handler.go | 78 + .../internal/handlers/pairing/handler_test.go | 10 + server/internal/handlers/ping/handler.go | 42 + server/internal/handlers/pools/handler.go | 86 + server/internal/handlers/schedule/handler.go | 111 + server/internal/handlers/static/handler.go | 7 + .../internal/handlers/telemetry/conversion.go | 323 + .../handlers/telemetry/conversion_test.go | 89 + server/internal/handlers/telemetry/handler.go | 117 + .../handlers/telemetry/handler_test.go | 451 + .../handlers/telemetry/handler_units_test.go | 225 + server/internal/infrastructure/db/config.go | 26 + .../infrastructure/db/database_connection.go | 146 + server/internal/infrastructure/db/retry.go | 136 + .../internal/infrastructure/db/retry_test.go | 117 + .../infrastructure/db/with_transaction.go | 98 + .../internal/infrastructure/encrypt/config.go | 5 + .../infrastructure/encrypt/service.go | 86 + .../internal/infrastructure/files/config.go | 10 + .../internal/infrastructure/files/firmware.go | 531 + .../infrastructure/files/firmware_test.go | 631 + .../internal/infrastructure/files/service.go | 367 + .../infrastructure/files/service_test.go | 208 + .../internal/infrastructure/foreman/client.go | 210 + .../infrastructure/foreman/client_test.go | 190 + .../internal/infrastructure/foreman/models.go | 95 + server/internal/infrastructure/id/id.go | 7 + .../internal/infrastructure/logging/logger.go | 22 + .../infrastructure/networking/network.go | 292 + .../infrastructure/networking/network_test.go | 129 + .../internal/infrastructure/queue/config.go | 6 + .../infrastructure/queue/interface.go | 49 + .../queue/mocks/mock_message_queue.go | 158 + .../internal/infrastructure/queue/service.go | 179 + .../internal/infrastructure/secrets/README.md | 25 + .../infrastructure/secrets/secrets.go | 54 + .../infrastructure/secrets/secrets_test.go | 143 + .../infrastructure/server/middleware.go | 7 + .../stratum/v1/authorization.go | 28 + .../infrastructure/stratum/v1/options.go | 17 + .../infrastructure/stratum/v1/stratum.go | 118 + .../infrastructure/stratum/v1/stratum_test.go | 197 + .../stratum/v1/testing/fake_authorize.go | 77 + .../stratum/v1/testing/fake_stratum.go | 123 + .../stratum/v1/testing/fake_stratum_test.go | 69 + .../timescaledb/aggregation_test.go | 584 + .../infrastructure/timescaledb/config.go | 41 + .../timescaledb/telemetry_store.go | 1526 +++ .../telemetry_store_bucket_range_test.go | 47 + .../timescaledb/telemetry_store_test.go | 531 + server/internal/testutil/config.go | 78 + server/internal/testutil/database_queries.go | 321 + server/internal/testutil/database_setup.go | 192 + .../testutil/infrastructure_provider.go | 149 + server/internal/testutil/mock_helpers.go | 25 + server/internal/testutil/service_provider.go | 182 + server/internal/testutil/test_auth_context.go | 30 + server/justfile | 111 + .../migrations/000001_initial_setup.down.sql | 12 + server/migrations/000001_initial_setup.up.sql | 75 + .../000002_create_core_tables.down.sql | 5 + .../000002_create_core_tables.up.sql | 122 + .../000003_create_device_tables.down.sql | 5 + .../000003_create_device_tables.up.sql | 145 + ...04_create_pool_and_command_tables.down.sql | 4 + ...0004_create_pool_and_command_tables.up.sql | 103 + ...reate_errors_and_telemetry_tables.down.sql | 3 + ..._create_errors_and_telemetry_tables.up.sql | 98 + ...0006_create_continuous_aggregates.down.sql | 10 + ...000006_create_continuous_aggregates.up.sql | 72 + .../000007_add_retention_policies.down.sql | 5 + .../000007_add_retention_policies.up.sql | 20 + .../000008_create_status_aggregates.down.sql | 7 + .../000008_create_status_aggregates.up.sql | 89 + .../000009_add_sort_indexes.down.sql | 11 + .../migrations/000009_add_sort_indexes.up.sql | 32 + .../000010_add_model_sort_index.down.sql | 6 + .../000010_add_model_sort_index.up.sql | 7 + .../000011_delete_miners_support.down.sql | 31 + .../000011_delete_miners_support.up.sql | 18 + ...2_create_device_collection_tables.down.sql | 7 + ...012_create_device_collection_tables.up.sql | 89 + .../000013_add_device_custom_name.down.sql | 1 + .../000013_add_device_custom_name.up.sql | 1 + .../000014_add_driver_name.down.sql | 4 + .../migrations/000014_add_driver_name.up.sql | 26 + .../000015_drop_type_column.down.sql | 8 + .../migrations/000015_drop_type_column.up.sql | 5 + ...00016_recreate_metrics_aggregates.down.sql | 72 + .../000016_recreate_metrics_aggregates.up.sql | 80 + .../000017_normalize_mac_addresses.down.sql | 2 + .../000017_normalize_mac_addresses.up.sql | 17 + ...000018_add_rack_order_and_cooling.down.sql | 3 + .../000018_add_rack_order_and_cooling.up.sql | 3 + .../000019_add_reaper_index.down.sql | 1 + .../migrations/000019_add_reaper_index.up.sql | 3 + ...ate_proto_grpc_discovery_endpoint.down.sql | 3 + ...grate_proto_grpc_discovery_endpoint.up.sql | 7 + .../000021_create_activity_log.down.sql | 1 + .../000021_create_activity_log.up.sql | 23 + .../000022_restore_worker_name.down.sql | 4 + .../000022_restore_worker_name.up.sql | 13 + ...3_normalize_legacy_pool_usernames.down.sql | 1 + ...023_normalize_legacy_pool_usernames.up.sql | 38 + .../000024_create_schedules.down.sql | 3 + .../migrations/000024_create_schedules.up.sql | 47 + ...0025_rename_rack_location_to_zone.down.sql | 1 + ...000025_rename_rack_location_to_zone.up.sql | 1 + ...6_rename_collection_to_device_set.down.sql | 25 + ...026_rename_collection_to_device_set.up.sql | 27 + .../000027_migrate_pyasic_to_asicrs.down.sql | 2 + .../000027_migrate_pyasic_to_asicrs.up.sql | 2 + .../000028_create_api_key_table.down.sql | 1 + .../000028_create_api_key_table.up.sql | 24 + ...d_firmware_update_device_statuses.down.sql | 2 + ...add_firmware_update_device_statuses.up.sql | 2 + ..._add_worker_name_pool_sync_status.down.sql | 4 + ...30_add_worker_name_pool_sync_status.up.sql | 6 + .../000031_remove_unused_indexes.down.sql | 11 + .../000031_remove_unused_indexes.up.sql | 16 + .../000032_autovacuum_tuning.down.sql | 4 + .../000032_autovacuum_tuning.up.sql | 14 + server/migrations/migration.go | 6 + server/miner-protos | 1 + server/sdk/v1/README.md | 1006 ++ server/sdk/v1/errors.go | 110 + server/sdk/v1/errors/types.go | 237 + server/sdk/v1/errors/types_test.go | 389 + server/sdk/v1/interface.go | 538 + server/sdk/v1/mocks/generate.go | 6 + server/sdk/v1/mocks/mock_driver.go | 1090 ++ server/sdk/v1/pb/buf.yaml | 4 + server/sdk/v1/pb/driver.proto | 651 + server/sdk/v1/pb/generated/driver.pb.go | 5374 ++++++++ server/sdk/v1/pb/generated/driver_grpc.pb.go | 1238 ++ server/sdk/v1/plugin.go | 1738 +++ server/sdk/v1/plugin_test.go | 1045 ++ server/sdk/v1/port_utils.go | 30 + server/sdk/v1/python/.gitignore | 57 + server/sdk/v1/python/LICENSE | 21 + server/sdk/v1/python/README.md | 205 + server/sdk/v1/python/justfile | 63 + .../sdk/v1/python/proto_fleet_sdk/__init__.py | 191 + .../v1/python/proto_fleet_sdk/capabilities.py | 160 + .../v1/python/proto_fleet_sdk/error_codes.py | 167 + .../sdk/v1/python/proto_fleet_sdk/errors.py | 264 + .../proto_fleet_sdk/generated/__init__.py | 1 + .../proto_fleet_sdk/generated/pb/__init__.py | 0 .../generated/pb/driver_pb2.py | 176 + .../generated/pb/driver_pb2.pyi | 793 ++ .../generated/pb/driver_pb2_grpc.py | 1361 ++ server/sdk/v1/python/proto_fleet_sdk/py.typed | 2 + .../sdk/v1/python/proto_fleet_sdk/server.py | 172 + .../python/proto_fleet_sdk/utils/__init__.py | 15 + .../utils/capability_helpers.py | 39 + .../proto_fleet_sdk/utils/http_utils.py | 81 + .../proto_fleet_sdk/utils/port_utils.py | 33 + server/sdk/v1/python/pyproject.toml | 95 + server/sdk/v1/python/tests/__init__.py | 1 + server/sdk/v1/python/tests/conftest.py | 1 + .../v1/python/tests/integration/__init__.py | 1 + server/sdk/v1/python/tests/test_http_utils.py | 88 + server/sdk/v1/python/tests/test_utils.py | 46 + server/sdk/v1/version.go | 23 + server/sqlc.yaml | 66 + server/sqlc/queries/activity.sql | 67 + server/sqlc/queries/api_key.sql | 32 + server/sqlc/queries/command.sql | 86 + server/sqlc/queries/device.sql | 728 + server/sqlc/queries/device_set.sql | 247 + server/sqlc/queries/discovered_device.sql | 114 + server/sqlc/queries/errors.sql | 266 + server/sqlc/queries/miner_credentials.sql | 15 + server/sqlc/queries/miner_service.sql | 45 + server/sqlc/queries/organization.sql | 52 + server/sqlc/queries/pool.sql | 45 + server/sqlc/queries/queue.sql | 119 + server/sqlc/queries/role.sql | 39 + server/sqlc/queries/schedule.sql | 184 + server/sqlc/queries/session.sql | 25 + server/sqlc/queries/telemetry.sql | 347 + server/sqlc/queries/user.sql | 91 + server/sqlc/queries/user_organization.sql | 42 + server/timescaledb/.dockerignore | 2 + server/timescaledb/Dockerfile | 126 + server/timescaledb/README.md | 182 + server/timescaledb/docker-entrypoint.sh | 170 + tests/plugin-contract/go.mod | 33 + tests/plugin-contract/go.sum | 85 + tests/plugin-contract/harness/antminer.go | 21 + tests/plugin-contract/harness/asicrs.go | 53 + tests/plugin-contract/harness/harness.go | 112 + .../miners/antminer_stock_test.go | 81 + .../miners/antminer_vnish_test.go | 120 + tests/plugin-contract/miners/contract.go | 337 + tests/plugin-contract/miners/manifest.go | 31 + .../miners/whatsminer_stock_test.go | 110 + .../mockapi/antminer/server.go | 326 + tests/plugin-contract/mockapi/mockapi.go | 43 + tests/plugin-contract/mockapi/vnish/server.go | 426 + .../mockapi/whatsminer/server.go | 450 + .../testdata/antminer-stock/manifest.json | 12 + .../testdata/antminer-stock/rpc/devs.json | 40 + .../testdata/antminer-stock/rpc/pools.json | 110 + .../testdata/antminer-stock/rpc/summary.json | 43 + .../testdata/antminer-stock/rpc/version.json | 21 + .../testdata/antminer-stock/web/blink.json | 1 + .../antminer-stock/web/get_miner_conf.json | 33 + .../antminer-stock/web/get_system_info.json | 15 + .../testdata/antminer-stock/web/reboot.json | 1 + .../antminer-stock/web/set_miner_conf.json | 1 + .../testdata/antminer-stock/web/stats.json | 68 + .../testdata/antminer-stock/web/summary.json | 36 + .../testdata/antminer-vnish/manifest.json | 13 + .../antminer-vnish/privileged/find-miner.json | 1 + .../privileged/mining/resume.json | 1 + .../privileged/mining/start.json | 1 + .../privileged/mining/stop.json | 1 + .../antminer-vnish/privileged/settings.json | 1 + .../privileged/system/reboot.json | 1 + .../antminer-vnish/rpc/devdetails.json | 1 + .../testdata/antminer-vnish/rpc/pools.json | 1 + .../testdata/antminer-vnish/rpc/stats.json | 1 + .../testdata/antminer-vnish/rpc/summary.json | 1 + .../testdata/antminer-vnish/rpc/version.json | 1 + .../antminer-vnish/web/autotune/presets.json | 1 + .../testdata/antminer-vnish/web/info.json | 1 + .../antminer-vnish/web/perf-summary.json | 1 + .../testdata/antminer-vnish/web/root.html | 27 + .../testdata/antminer-vnish/web/settings.json | 1 + .../testdata/antminer-vnish/web/summary.json | 1 + .../testdata/antminer-vnish/web/unlock.json | 1 + .../testdata/whatsminer-stock/manifest.json | 12 + .../privileged/power_off.json | 7 + .../whatsminer-stock/privileged/power_on.json | 7 + .../whatsminer-stock/privileged/reboot.json | 7 + .../privileged/set_high_power.json | 7 + .../whatsminer-stock/privileged/set_led.json | 7 + .../privileged/set_low_power.json | 7 + .../privileged/set_normal_power.json | 7 + .../privileged/update_pools.json | 7 + .../whatsminer-stock/rpc/devdetails.json | 35 + .../testdata/whatsminer-stock/rpc/devs.json | 74 + .../testdata/whatsminer-stock/rpc/edevs.json | 56 + .../whatsminer-stock/rpc/get_error_code.json | 9 + .../whatsminer-stock/rpc/get_miner_info.json | 14 + .../whatsminer-stock/rpc/get_psu.json | 20 + .../whatsminer-stock/rpc/get_token.json | 11 + .../whatsminer-stock/rpc/get_version.json | 12 + .../testdata/whatsminer-stock/rpc/pools.json | 80 + .../testdata/whatsminer-stock/rpc/status.json | 10 + .../whatsminer-stock/rpc/summary.json | 51 + .../whatsminer-stock/rpc/version.json | 12 + 2506 files changed, 441969 insertions(+), 43 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/miner-compatibility.yml create mode 100644 .github/actions/deploy-protofleet/action.yml create mode 100644 .github/actions/go-cache-setup/action.yml create mode 100644 .github/actions/hermit-setup/action.yml create mode 100644 .github/client-e2e-tests.yml create mode 100644 .github/copilot-instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/labeler.yml create mode 100644 .github/release-configs/web.json create mode 100644 .github/workflows/RASPBERRY_PI_DEPLOYMENT.md create mode 100644 .github/workflows/asicrs-plugin-checks.yml create mode 100644 .github/workflows/codex-security-review.yml create mode 100644 .github/workflows/generated-code-check.yml create mode 100644 .github/workflows/powershell-lint.yml create mode 100644 .github/workflows/protofleet-antminer-plugin-checks.yml create mode 100644 .github/workflows/protofleet-client-checks.yml create mode 100644 .github/workflows/protofleet-contract-checks.yml create mode 100644 .github/workflows/protofleet-deploy-to-pi.yml create mode 100644 .github/workflows/protofleet-e2e-real-miners-manual.yml create mode 100644 .github/workflows/protofleet-e2e-tests.yml create mode 100644 .github/workflows/protofleet-example-python-plugin-checks.yml create mode 100644 .github/workflows/protofleet-proto-checks.yml create mode 100644 .github/workflows/protofleet-proto-plugin-checks.yml create mode 100644 .github/workflows/protofleet-server-checks.yml create mode 100644 .github/workflows/protofleet-virtual-plugin-checks.yml create mode 100644 .github/workflows/protoos-e2e-tests.yml create mode 100644 .github/workflows/pull-request.yml create mode 100644 .github/workflows/python-tests.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/rust-sdk-checks.yml create mode 100644 .github/workflows/windows-csharp-checks.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Procfile create mode 100644 SECURITY.md create mode 120000 bin/.buf-1.57.2.pkg create mode 120000 bin/.gh-2.53.0.pkg create mode 120000 bin/.git-absorb-0.7.0.pkg create mode 120000 bin/.go-1.25.4.pkg create mode 120000 bin/.goimports-0.3.0.pkg create mode 120000 bin/.golangci-lint-2.6.2.pkg create mode 120000 bin/.grpcurl-1.9.2.pkg create mode 120000 bin/.jq-1.7.1.pkg create mode 120000 bin/.just-1.40.0.pkg create mode 120000 bin/.lefthook-2.1.4.pkg create mode 120000 bin/.migrate-4.18.2.pkg create mode 120000 bin/.mockgen-1.6.0.pkg create mode 120000 bin/.mysql-client-8.0.36.pkg create mode 120000 bin/.nfpm-2.45.0.pkg create mode 120000 bin/.node-22.14.0.pkg create mode 120000 bin/.proto-python-gen-0.2.0.pkg create mode 120000 bin/.protoc-30.2.pkg create mode 120000 bin/.protoc-gen-connect-go-1.12.0.pkg create mode 120000 bin/.protoc-gen-go-1.36.5.pkg create mode 120000 bin/.protoc-gen-go-grpc-1.3.0.pkg create mode 120000 bin/.python3-3.13.2.pkg create mode 120000 bin/.sqlc-1.28.0.pkg create mode 100644 bin/README.hermit.md create mode 100755 bin/activate-hermit create mode 100755 bin/activate-hermit.fish create mode 120000 bin/buf create mode 120000 bin/corepack create mode 120000 bin/gh create mode 120000 bin/git-absorb create mode 120000 bin/go create mode 120000 bin/gofmt create mode 120000 bin/goimports create mode 120000 bin/golangci-lint create mode 120000 bin/grpcurl create mode 100755 bin/hermit create mode 100644 bin/hermit.hcl create mode 120000 bin/jq create mode 120000 bin/just create mode 120000 bin/lefthook create mode 120000 bin/migrate create mode 120000 bin/mockgen create mode 120000 bin/mysql create mode 120000 bin/nfpm create mode 120000 bin/node create mode 120000 bin/npm create mode 120000 bin/npx create mode 120000 bin/pip create mode 120000 bin/pip3 create mode 120000 bin/pip3.13 create mode 120000 bin/protoc create mode 120000 bin/protoc-gen-buf-breaking create mode 120000 bin/protoc-gen-buf-lint create mode 120000 bin/protoc-gen-connect-go create mode 120000 bin/protoc-gen-go create mode 120000 bin/protoc-gen-go-grpc create mode 120000 bin/protoc-gen-python-grpc create mode 120000 bin/pydoc3 create mode 120000 bin/pydoc3.13 create mode 120000 bin/python create mode 120000 bin/python3 create mode 120000 bin/python3-config create mode 120000 bin/python3.13 create mode 120000 bin/python3.13-config create mode 120000 bin/sqlc create mode 100644 buf.gen.yaml create mode 100644 buf.lock create mode 100644 buf.yaml create mode 100644 client/.dockerignore create mode 100644 client/.gitignore create mode 100644 client/.npmrc create mode 100644 client/.prettierignore create mode 100644 client/.prettierrc.js create mode 100644 client/.storybook/main.ts create mode 100644 client/.storybook/preview-body.html create mode 100644 client/.storybook/preview.tsx create mode 100644 client/README.md create mode 100644 client/e2eTests/protoFleet/.gitignore create mode 100644 client/e2eTests/protoFleet/README.md create mode 100644 client/e2eTests/protoFleet/config/test.config.defaults.ts create mode 100644 client/e2eTests/protoFleet/config/test.config.local.example.ts create mode 100644 client/e2eTests/protoFleet/config/test.config.ts create mode 100644 client/e2eTests/protoFleet/fixtures/pageFixtures.ts create mode 100644 client/e2eTests/protoFleet/helpers/commonSteps.ts create mode 100644 client/e2eTests/protoFleet/helpers/minerModels.ts create mode 100644 client/e2eTests/protoFleet/helpers/testDataHelper.ts create mode 100644 client/e2eTests/protoFleet/pages/addMiners.ts create mode 100644 client/e2eTests/protoFleet/pages/auth.ts create mode 100644 client/e2eTests/protoFleet/pages/base.ts create mode 100644 client/e2eTests/protoFleet/pages/components/loginModal.ts create mode 100644 client/e2eTests/protoFleet/pages/components/modalMinerSelectionList.ts create mode 100644 client/e2eTests/protoFleet/pages/editPool.ts create mode 100644 client/e2eTests/protoFleet/pages/groups.ts create mode 100644 client/e2eTests/protoFleet/pages/home.ts create mode 100644 client/e2eTests/protoFleet/pages/miners.ts create mode 100644 client/e2eTests/protoFleet/pages/newPoolModal.ts create mode 100644 client/e2eTests/protoFleet/pages/racks.ts create mode 100644 client/e2eTests/protoFleet/pages/settings.ts create mode 100644 client/e2eTests/protoFleet/pages/settingsApiKeys.ts create mode 100644 client/e2eTests/protoFleet/pages/settingsFirmware.ts create mode 100644 client/e2eTests/protoFleet/pages/settingsPools.ts create mode 100644 client/e2eTests/protoFleet/pages/settingsSchedules.ts create mode 100644 client/e2eTests/protoFleet/pages/settingsSecurity.ts create mode 100644 client/e2eTests/protoFleet/pages/settingsTeam.ts create mode 100644 client/e2eTests/protoFleet/playwright.config.ts create mode 100644 client/e2eTests/protoFleet/spec/00-onboarding.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/01-miningPools.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/auth.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/firmware.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/generalSettings.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/groups.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/minerIssues.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/minersActions.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/minersRename.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/minersSleepWake.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/navigation.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/racks.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/securitySettings.spec.ts create mode 100644 client/e2eTests/protoFleet/spec/teamAccounts.spec.ts create mode 100644 client/e2eTests/protoOS/.gitignore create mode 100644 client/e2eTests/protoOS/README.md create mode 100644 client/e2eTests/protoOS/config/test.config.defaults.ts create mode 100644 client/e2eTests/protoOS/config/test.config.local.d.ts create mode 100644 client/e2eTests/protoOS/config/test.config.local.example.ts create mode 100644 client/e2eTests/protoOS/config/test.config.ts create mode 100644 client/e2eTests/protoOS/fixtures/pageFixtures.ts create mode 100644 client/e2eTests/protoOS/helpers/commonSteps.ts create mode 100644 client/e2eTests/protoOS/helpers/testDataHelper.ts create mode 100644 client/e2eTests/protoOS/pages/authentication.ts create mode 100644 client/e2eTests/protoOS/pages/base.ts create mode 100644 client/e2eTests/protoOS/pages/components/header.ts create mode 100644 client/e2eTests/protoOS/pages/components/navigation.ts create mode 100644 client/e2eTests/protoOS/pages/components/sleepWakeDialog.ts create mode 100644 client/e2eTests/protoOS/pages/components/wakeCallout.ts create mode 100644 client/e2eTests/protoOS/pages/cooling.ts create mode 100644 client/e2eTests/protoOS/pages/diagnostics.ts create mode 100644 client/e2eTests/protoOS/pages/general.ts create mode 100644 client/e2eTests/protoOS/pages/hardware.ts create mode 100644 client/e2eTests/protoOS/pages/home.ts create mode 100644 client/e2eTests/protoOS/pages/logs.ts create mode 100644 client/e2eTests/protoOS/pages/onboarding.ts create mode 100644 client/e2eTests/protoOS/pages/pools.ts create mode 100644 client/e2eTests/protoOS/playwright.config.ts create mode 100644 client/e2eTests/protoOS/spec/00-onboarding.spec.ts create mode 100644 client/e2eTests/protoOS/spec/dashboard.spec.ts create mode 100644 client/e2eTests/protoOS/spec/diagnostics.spec.ts create mode 100644 client/e2eTests/protoOS/spec/pools.spec.ts create mode 100644 client/e2eTests/protoOS/spec/power.spec.ts create mode 100644 client/e2eTests/protoOS/spec/temperature.spec.ts create mode 100644 client/eslint.config.js create mode 100644 client/nfpm-proto-os.yaml create mode 100644 client/nginx.runner-protofleet.conf create mode 100644 client/package-lock.json create mode 100644 client/package.json create mode 100644 client/postcss.config.js create mode 100644 client/public/favicon.png create mode 100644 client/public/fonts/Inter/InterVariable-Italic.woff2 create mode 100644 client/public/fonts/Inter/InterVariable.woff2 create mode 100644 client/public/fonts/JetBrainsMono/JetBrainsMono-Italic[wght].ttf create mode 100644 client/public/fonts/JetBrainsMono/JetBrainsMono[wght].ttf create mode 100644 client/scripts/auth_discover_pair.ts create mode 100644 client/scripts/dev-protoOS.ts create mode 100644 client/scripts/generate_api_ts.mjs create mode 100644 client/src/protoFleet/api/ScheduleApiContext.ts create mode 100644 client/src/protoFleet/api/ScheduleApiProvider.tsx create mode 100644 client/src/protoFleet/api/clients.ts create mode 100644 client/src/protoFleet/api/constants.ts create mode 100644 client/src/protoFleet/api/fetchAllMinerSnapshots.test.ts create mode 100644 client/src/protoFleet/api/fetchAllMinerSnapshots.ts create mode 100644 client/src/protoFleet/api/generated/activity/v1/activity_pb.ts create mode 100644 client/src/protoFleet/api/generated/apikey/v1/apikey_pb.ts create mode 100644 client/src/protoFleet/api/generated/auth/v1/auth_pb.ts create mode 100644 client/src/protoFleet/api/generated/buf/validate/validate_pb.ts create mode 100644 client/src/protoFleet/api/generated/capabilities/v1/capabilities_pb.ts create mode 100644 client/src/protoFleet/api/generated/collection/v1/collection_pb.ts create mode 100644 client/src/protoFleet/api/generated/common/v1/common_pb.ts create mode 100644 client/src/protoFleet/api/generated/common/v1/cooling_pb.ts create mode 100644 client/src/protoFleet/api/generated/common/v1/device_selector_pb.ts create mode 100644 client/src/protoFleet/api/generated/common/v1/measurement_pb.ts create mode 100644 client/src/protoFleet/api/generated/common/v1/sort_pb.ts create mode 100644 client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts create mode 100644 client/src/protoFleet/api/generated/errors/v1/errors_pb.ts create mode 100644 client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts create mode 100644 client/src/protoFleet/api/generated/fleetperformance/v1/fleetperformance_pb.ts create mode 100644 client/src/protoFleet/api/generated/foremanimport/v1/foremanimport_pb.ts create mode 100644 client/src/protoFleet/api/generated/minercommand/v1/command_pb.ts create mode 100644 client/src/protoFleet/api/generated/networkinfo/v1/networkinfo_pb.ts create mode 100644 client/src/protoFleet/api/generated/onboarding/v1/onboarding_pb.ts create mode 100644 client/src/protoFleet/api/generated/pairing/v1/pairing_pb.ts create mode 100644 client/src/protoFleet/api/generated/ping/v1/ping_pb.ts create mode 100644 client/src/protoFleet/api/generated/pools/v1/pools_pb.ts create mode 100644 client/src/protoFleet/api/generated/schedule/v1/schedule_pb.ts create mode 100644 client/src/protoFleet/api/generated/telemetry/v1/telemetry_pb.ts create mode 100644 client/src/protoFleet/api/getErrorMessage.test.ts create mode 100644 client/src/protoFleet/api/getErrorMessage.ts create mode 100644 client/src/protoFleet/api/scheduleEvents.ts create mode 100644 client/src/protoFleet/api/transport.ts create mode 100644 client/src/protoFleet/api/useActivity.test.ts create mode 100644 client/src/protoFleet/api/useActivity.ts create mode 100644 client/src/protoFleet/api/useActivityFilterOptions.ts create mode 100644 client/src/protoFleet/api/useApiKeys.ts create mode 100644 client/src/protoFleet/api/useAuth.ts create mode 100644 client/src/protoFleet/api/useAuthNeededMiners.test.ts create mode 100644 client/src/protoFleet/api/useAuthNeededMiners.ts create mode 100644 client/src/protoFleet/api/useComponentErrors.test.ts create mode 100644 client/src/protoFleet/api/useComponentErrors.ts create mode 100644 client/src/protoFleet/api/useDeviceErrors.ts create mode 100644 client/src/protoFleet/api/useDeviceSetStateCounts.test.ts create mode 100644 client/src/protoFleet/api/useDeviceSetStateCounts.ts create mode 100644 client/src/protoFleet/api/useDeviceSets.test.ts create mode 100644 client/src/protoFleet/api/useDeviceSets.ts create mode 100644 client/src/protoFleet/api/useExportActivity.ts create mode 100644 client/src/protoFleet/api/useExportMinerListCsv.ts create mode 100644 client/src/protoFleet/api/useFileUpload.test.ts create mode 100644 client/src/protoFleet/api/useFileUpload.ts create mode 100644 client/src/protoFleet/api/useFirmwareApi.test.ts create mode 100644 client/src/protoFleet/api/useFirmwareApi.ts create mode 100644 client/src/protoFleet/api/useFleet.test.ts create mode 100644 client/src/protoFleet/api/useFleet.ts create mode 100644 client/src/protoFleet/api/useFleetCounts.ts create mode 100644 client/src/protoFleet/api/useForemanImport.ts create mode 100644 client/src/protoFleet/api/useLogin.ts create mode 100644 client/src/protoFleet/api/useLogout.ts create mode 100644 client/src/protoFleet/api/useMinerCommand.ts create mode 100644 client/src/protoFleet/api/useMinerCoolingMode.ts create mode 100644 client/src/protoFleet/api/useMinerModelGroups.ts create mode 100644 client/src/protoFleet/api/useMinerPairing.ts create mode 100644 client/src/protoFleet/api/useMinerPoolAssignments.ts create mode 100644 client/src/protoFleet/api/useNetworkInfo.ts create mode 100644 client/src/protoFleet/api/useOnboardedStatus.ts create mode 100644 client/src/protoFleet/api/usePoolNeededCount.ts create mode 100644 client/src/protoFleet/api/usePools.ts create mode 100644 client/src/protoFleet/api/useRenameMiners.ts create mode 100644 client/src/protoFleet/api/useScheduleApi.test.ts create mode 100644 client/src/protoFleet/api/useScheduleApi.timezone.test.ts create mode 100644 client/src/protoFleet/api/useScheduleApi.ts create mode 100644 client/src/protoFleet/api/useTelemetryMetrics.test.ts create mode 100644 client/src/protoFleet/api/useTelemetryMetrics.ts create mode 100644 client/src/protoFleet/api/useUpdateWorkerNames.test.ts create mode 100644 client/src/protoFleet/api/useUpdateWorkerNames.ts create mode 100644 client/src/protoFleet/api/useUserManagement.ts create mode 100644 client/src/protoFleet/components/App/App.test.tsx create mode 100644 client/src/protoFleet/components/App/App.tsx create mode 100644 client/src/protoFleet/components/App/index.ts create mode 100644 client/src/protoFleet/components/AppLayout/AppLayout.test.tsx create mode 100644 client/src/protoFleet/components/AppLayout/AppLayout.tsx create mode 100644 client/src/protoFleet/components/AppLayout/index.ts create mode 100644 client/src/protoFleet/components/DeviceSetList/DeviceSetList.test.tsx create mode 100644 client/src/protoFleet/components/DeviceSetList/DeviceSetList.tsx create mode 100644 client/src/protoFleet/components/DeviceSetList/StatCell.tsx create mode 100644 client/src/protoFleet/components/DeviceSetList/constants.ts create mode 100644 client/src/protoFleet/components/DeviceSetList/deviceSetColConfig.tsx create mode 100644 client/src/protoFleet/components/DeviceSetList/index.ts create mode 100644 client/src/protoFleet/components/DeviceSetList/issueFilterConstants.ts create mode 100644 client/src/protoFleet/components/DeviceSetList/sortConfig.test.ts create mode 100644 client/src/protoFleet/components/DeviceSetList/sortConfig.ts create mode 100644 client/src/protoFleet/components/FirmwareUpload/FirmwareUploadComponents.tsx create mode 100644 client/src/protoFleet/components/FirmwareUpload/index.ts create mode 100644 client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.test.ts create mode 100644 client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.ts create mode 100644 client/src/protoFleet/components/Footer/Footer.tsx create mode 100644 client/src/protoFleet/components/Footer/index.ts create mode 100644 client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.stories.tsx create mode 100644 client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.tsx create mode 100644 client/src/protoFleet/components/FullScreenTwoPaneModal/index.ts create mode 100644 client/src/protoFleet/components/LineChart/LineChart.tsx create mode 100644 client/src/protoFleet/components/LineChart/index.ts create mode 100644 client/src/protoFleet/components/MinerSelectionList.tsx create mode 100644 client/src/protoFleet/components/MiningPools/MiningPoolsForm.stories.tsx create mode 100644 client/src/protoFleet/components/MiningPools/MiningPoolsForm.test.tsx create mode 100644 client/src/protoFleet/components/MiningPools/MiningPoolsForm.tsx create mode 100644 client/src/protoFleet/components/MiningPools/index.ts create mode 100644 client/src/protoFleet/components/NavigationMenu/FloatingNavigation.tsx create mode 100644 client/src/protoFleet/components/NavigationMenu/Navigation.tsx create mode 100644 client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx create mode 100644 client/src/protoFleet/components/NavigationMenu/NavigationMenu.test.tsx create mode 100644 client/src/protoFleet/components/NavigationMenu/NavigationMenu.tsx create mode 100644 client/src/protoFleet/components/NavigationMenu/constants.ts create mode 100644 client/src/protoFleet/components/NavigationMenu/index.ts create mode 100644 client/src/protoFleet/components/NoFilterResultsEmptyState.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.stories.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.test.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BankBalance/BankBalanceWrapper.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BankBalance/constants.ts create mode 100644 client/src/protoFleet/components/PageHeader/BankBalance/index.ts create mode 100644 client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.stories.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.test.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRateWrapper.tsx create mode 100644 client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/index.ts create mode 100644 client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.stories.tsx create mode 100644 client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.test.tsx create mode 100644 client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.tsx create mode 100644 client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelectorWrapper.tsx create mode 100644 client/src/protoFleet/components/PageHeader/LocationSelector/index.ts create mode 100644 client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx create mode 100644 client/src/protoFleet/components/PageHeader/PageHeader.test.tsx create mode 100644 client/src/protoFleet/components/PageHeader/PageHeader.tsx create mode 100644 client/src/protoFleet/components/PageHeader/SchedulePill.test.tsx create mode 100644 client/src/protoFleet/components/PageHeader/SchedulePill.tsx create mode 100644 client/src/protoFleet/components/PageHeader/SchedulePopover.test.tsx create mode 100644 client/src/protoFleet/components/PageHeader/SchedulePopover.tsx create mode 100644 client/src/protoFleet/components/PageHeader/index.ts create mode 100644 client/src/protoFleet/components/PageHeader/schedulePillUtils.ts create mode 100644 client/src/protoFleet/components/PageHeader/useSchedulePillData.test.tsx create mode 100644 client/src/protoFleet/components/PageHeader/useSchedulePillData.ts create mode 100644 client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.stories.tsx create mode 100644 client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.test.tsx create mode 100644 client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.tsx create mode 100644 client/src/protoFleet/components/SecondaryNavigation/index.ts create mode 100644 client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx create mode 100644 client/src/protoFleet/components/SingleMinerWrapper/index.ts create mode 100644 client/src/protoFleet/components/StatusModal/StatusModal.tsx create mode 100644 client/src/protoFleet/components/StatusModal/constants.ts create mode 100644 client/src/protoFleet/components/StatusModal/hooks/index.ts create mode 100644 client/src/protoFleet/components/StatusModal/hooks/useStatusModalHooks.ts create mode 100644 client/src/protoFleet/components/StatusModal/index.ts create mode 100644 client/src/protoFleet/components/StatusModal/types.ts create mode 100644 client/src/protoFleet/components/StatusModal/utils.ts create mode 100644 client/src/protoFleet/config/navItems.ts create mode 100644 client/src/protoFleet/constants/polling.ts create mode 100644 client/src/protoFleet/features/activity/components/ActivityFilters.tsx create mode 100644 client/src/protoFleet/features/activity/components/ActivityTable.tsx create mode 100644 client/src/protoFleet/features/activity/index.ts create mode 100644 client/src/protoFleet/features/activity/pages/ActivityPage.tsx create mode 100644 client/src/protoFleet/features/activity/utils/activityIcons.tsx create mode 100644 client/src/protoFleet/features/activity/utils/formatLabel.ts create mode 100644 client/src/protoFleet/features/activity/utils/formatScope.ts create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.stories.tsx create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.tsx create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateFleetModal/index.ts create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.stories.tsx create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.test.tsx create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.tsx create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateMiners/constants.ts create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateMiners/index.ts create mode 100644 client/src/protoFleet/features/auth/components/AuthenticateMiners/types.ts create mode 100644 client/src/protoFleet/features/auth/components/LoginModal/LoginForm.tsx create mode 100644 client/src/protoFleet/features/auth/components/LoginModal/constants.ts create mode 100644 client/src/protoFleet/features/auth/components/LoginModal/index.ts create mode 100644 client/src/protoFleet/features/auth/components/LoginModal/types.ts create mode 100644 client/src/protoFleet/features/auth/components/UpdatePasswordForm.stories.tsx create mode 100644 client/src/protoFleet/features/auth/components/UpdatePasswordForm.test.tsx create mode 100644 client/src/protoFleet/features/auth/components/UpdatePasswordForm.tsx create mode 100644 client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.stories.tsx create mode 100644 client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.test.tsx create mode 100644 client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.tsx create mode 100644 client/src/protoFleet/features/auth/components/index.ts create mode 100644 client/src/protoFleet/features/auth/pages/Auth/Auth.tsx create mode 100644 client/src/protoFleet/features/auth/pages/Auth/index.ts create mode 100644 client/src/protoFleet/features/auth/pages/UpdatePassword/UpdatePassword.tsx create mode 100644 client/src/protoFleet/features/auth/pages/UpdatePassword/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.stories.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.test.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/ChartWidget/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.test.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/EfficiencyPanel/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.test.ts create mode 100644 client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.ts create mode 100644 client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.stories.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.test.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/FleetHealth/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/HashratePanel/HashratePanel.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/HashratePanel/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/HashratePanel/utils.test.ts create mode 100644 client/src/protoFleet/features/dashboard/components/HashratePanel/utils.ts create mode 100644 client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.test.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/PowerPanel/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/PowerPanel/utils.test.ts create mode 100644 client/src/protoFleet/features/dashboard/components/PowerPanel/utils.ts create mode 100644 client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.stories.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.test.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/SectionHeading/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.stories.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/constants.ts create mode 100644 client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/types.ts create mode 100644 client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.test.ts create mode 100644 client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.ts create mode 100644 client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/StatusBreakdownPanel.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.stories.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/TemperaturePanel/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/TemperaturePanel/utils.ts create mode 100644 client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.stories.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.test.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.tsx create mode 100644 client/src/protoFleet/features/dashboard/components/UptimePanel/index.ts create mode 100644 client/src/protoFleet/features/dashboard/components/UptimePanel/utils.test.ts create mode 100644 client/src/protoFleet/features/dashboard/components/UptimePanel/utils.ts create mode 100644 client/src/protoFleet/features/dashboard/constants.ts create mode 100644 client/src/protoFleet/features/dashboard/index.ts create mode 100644 client/src/protoFleet/features/dashboard/pages/Dashboard.tsx create mode 100644 client/src/protoFleet/features/dashboard/types.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/chartDataPadding.test.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/chartDataPadding.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/createMockMetric.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/granularity.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/metricNormalization.test.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/metricNormalization.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.test.ts create mode 100644 client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolActionsMenu.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolRow.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPageWrapper.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/PoolsList.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolsList/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/constants.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/types.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/ActionBar/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionConfirmDialog.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsPopover.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/UnsupportedMinersModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/BulkActions/types.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/Fleet/Fleet.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/Fleet/constants.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/Fleet/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/AddToGroupModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameDialogs.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameOptionModals.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePreviewPanel.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenamePropertyForm.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkRenameToasts.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/BulkWorkerNameModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/CoolingModeModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/CoolingModeModal/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/ManagePowerModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManagePowerModal/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/ManageSecurityModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/UpdateMinerPasswordModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/ManageSecurity/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/MinerActionsMenu.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameMinerDialog.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyOptionsModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/CustomPropertyTypeDropdown.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/FixedValueOptionsModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/HighlightedNamePreview.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/InlineRadioGroup.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/QualifierOptionsModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/RenameOptionsModals.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/constants.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/storyPreviewBuilders.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/RenameOptionsModals/types.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/SingleMinerActionsMenu.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/UpdateWorkerNameDialog.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/actionMenuUtils.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameDefinitions.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenamePreview.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/bulkRenameToastMessages.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useFleetAuthentication.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useManageSecurityFlow.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/useMinerActions.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/waitForWorkerNameBatchResult.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/ManageColumnsModal.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerEfficiency.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerFirmware.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerGroups.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerHashrate.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIpAddress.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssues.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerIssuesCell.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.modalFlow.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerList.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerListActionBar.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMacAddress.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerMeasurement.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerModel.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerName.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerPowerUsage.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatus.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerStatusCell.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerTemperature.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/MinerWorkerName.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/UnsupportedMetric.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/constants.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/minerColConfig.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/minerTableColumnPreferences.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/sortConfig.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/stories/MinerList.stories.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/stories/mocks.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/stories/statusMocks.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/types.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/useMinerTableColumnPreferences.ts create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/utils.test.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/components/MinerList/utils.tsx create mode 100644 client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/hooks/useBatchOperations.ts create mode 100644 client/src/protoFleet/features/fleetManagement/index.ts create mode 100644 client/src/protoFleet/features/fleetManagement/types.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/batchStatusCheck.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/deviceSelector.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/deviceSelector.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/filterUrlParams.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/fleetVisiblePairingFilter.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/getMinerMeasurement.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.test.ts create mode 100644 client/src/protoFleet/features/fleetManagement/utils/sortUrlParams.ts create mode 100644 client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.test.tsx create mode 100644 client/src/protoFleet/features/groupManagement/components/DeviceSetActionsMenu.tsx create mode 100644 client/src/protoFleet/features/groupManagement/components/DeviceSetPerformanceSection.tsx create mode 100644 client/src/protoFleet/features/groupManagement/components/GroupModal.stories.tsx create mode 100644 client/src/protoFleet/features/groupManagement/components/GroupModal.tsx create mode 100644 client/src/protoFleet/features/groupManagement/components/GroupsTable/GroupNameCell.tsx create mode 100644 client/src/protoFleet/features/groupManagement/components/GroupsTable/index.ts create mode 100644 client/src/protoFleet/features/groupManagement/index.ts create mode 100644 client/src/protoFleet/features/groupManagement/pages/GroupOverviewPage.tsx create mode 100644 client/src/protoFleet/features/groupManagement/pages/GroupsPage.tsx create mode 100644 client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.stories.tsx create mode 100644 client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.test.tsx create mode 100644 client/src/protoFleet/features/kpis/components/ComponentErrors/ComponentErrors.tsx create mode 100644 client/src/protoFleet/features/kpis/components/ComponentErrors/index.ts create mode 100644 client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.stories.tsx create mode 100644 client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.test.tsx create mode 100644 client/src/protoFleet/features/kpis/components/FleetErrors/FleetErrors.tsx create mode 100644 client/src/protoFleet/features/kpis/components/FleetErrors/index.ts create mode 100644 client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.stories.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.test.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/CompleteSetup/CompleteSetup.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/CompleteSetup/index.ts create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/FoundMiners.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.stories.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/FoundMinersModal.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/Miners.stories.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/Miners.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/MinersWrapper.test.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/MinersWrapper.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/ValidationErrorDialog.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/index.ts create mode 100644 client/src/protoFleet/features/onboarding/components/Miners/types.ts create mode 100644 client/src/protoFleet/features/onboarding/components/Security/SecurityPage.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Security/index.ts create mode 100644 client/src/protoFleet/features/onboarding/components/Settings/SettingsPage.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Settings/index.ts create mode 100644 client/src/protoFleet/features/onboarding/components/Welcome/WelcomePage.tsx create mode 100644 client/src/protoFleet/features/onboarding/components/Welcome/index.ts create mode 100644 client/src/protoFleet/features/onboarding/constants.ts create mode 100644 client/src/protoFleet/features/onboarding/index.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/AssignMinersModal.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/AssignMinersModal.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/ManageMinersModal.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/ManageMinersModal.test.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/ManageMinersModal.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/MinersPane.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/RackPane.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/SearchMinersModal.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/SearchMinersModal.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/index.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/AssignMinersModal/types.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/RackCard/MiniRackGrid.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackCard/MiniRackGrid.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackCard/RackCard.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackCard/RackCard.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackCard/RackCardGrid.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackCard/index.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/RackCard/types.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/RackDetailGrid/RackDetailGrid.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackDetailGrid/RackDetailGrid.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackDetailGrid/RackDetailSlot.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackDetailGrid/index.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/RackDetailGrid/types.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/RackHealthModule/RackHealthModule.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackHealthModule/index.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/RackSettingsModal.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackSettingsModal.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackSlotGrid/RackSlot.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackSlotGrid/RackSlotGrid.stories.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackSlotGrid/RackSlotGrid.tsx create mode 100644 client/src/protoFleet/features/rackManagement/components/RackSlotGrid/index.ts create mode 100644 client/src/protoFleet/features/rackManagement/components/RackSlotGrid/types.ts create mode 100644 client/src/protoFleet/features/rackManagement/index.ts create mode 100644 client/src/protoFleet/features/rackManagement/pages/RackOverviewPage.test.tsx create mode 100644 client/src/protoFleet/features/rackManagement/pages/RackOverviewPage.tsx create mode 100644 client/src/protoFleet/features/rackManagement/pages/RacksPage.tsx create mode 100644 client/src/protoFleet/features/rackManagement/utils/rackCardMapper.test.ts create mode 100644 client/src/protoFleet/features/rackManagement/utils/rackCardMapper.ts create mode 100644 client/src/protoFleet/features/rackManagement/utils/slotNumbering.ts create mode 100644 client/src/protoFleet/features/settings/components/AddTeamMemberModal.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/AddTeamMemberModal.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/AddTeamMemberModal.tsx create mode 100644 client/src/protoFleet/features/settings/components/ApiKeys.tsx create mode 100644 client/src/protoFleet/features/settings/components/Auth.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/Auth.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Auth.tsx create mode 100644 client/src/protoFleet/features/settings/components/Cooling.tsx create mode 100644 client/src/protoFleet/features/settings/components/CreateApiKeyModal.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/CreateApiKeyModal.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/CreateApiKeyModal.tsx create mode 100644 client/src/protoFleet/features/settings/components/DeactivateUserDialog.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/DeactivateUserDialog.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/DeactivateUserDialog.tsx create mode 100644 client/src/protoFleet/features/settings/components/DeleteAllFirmwareDialog.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/DeleteAllFirmwareDialog.tsx create mode 100644 client/src/protoFleet/features/settings/components/DeleteFirmwareDialog.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/DeleteFirmwareDialog.tsx create mode 100644 client/src/protoFleet/features/settings/components/Firmware.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Firmware.tsx create mode 100644 client/src/protoFleet/features/settings/components/FirmwareUploadDialog.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/FirmwareUploadDialog.tsx create mode 100644 client/src/protoFleet/features/settings/components/General.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/General.tsx create mode 100644 client/src/protoFleet/features/settings/components/Hardware.tsx create mode 100644 client/src/protoFleet/features/settings/components/MiningPools.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/MiningPools.tsx create mode 100644 client/src/protoFleet/features/settings/components/MiningPools.utils.test.ts create mode 100644 client/src/protoFleet/features/settings/components/ResetPasswordModal.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/ResetPasswordModal.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/ResetPasswordModal.tsx create mode 100644 client/src/protoFleet/features/settings/components/RevokeApiKeyDialog.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/GroupSelectionModal.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/GroupSelectionModal.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/MinerSelectionModal.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/MinerSelectionModal.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/MinerSelectionModal.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/RackSelectionModal.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/RackSelectionModal.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/RackSelectionModal.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/ScheduleModal.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/ScheduleModal.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/ScheduleModal.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/SchedulePreview.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/SchedulePreview.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/SchedulesPage.test.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/SchedulesPage.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/SchedulesTable.stories.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/constants.test.ts create mode 100644 client/src/protoFleet/features/settings/components/Schedules/constants.ts create mode 100644 client/src/protoFleet/features/settings/components/Schedules/scheduleColConfig.tsx create mode 100644 client/src/protoFleet/features/settings/components/Schedules/scheduleRunUtils.test.ts create mode 100644 client/src/protoFleet/features/settings/components/Schedules/scheduleRunUtils.ts create mode 100644 client/src/protoFleet/features/settings/components/Schedules/scheduleValidation.test.ts create mode 100644 client/src/protoFleet/features/settings/components/Schedules/scheduleValidation.ts create mode 100644 client/src/protoFleet/features/settings/components/SettingsLayout.tsx create mode 100644 client/src/protoFleet/features/settings/components/Team.tsx create mode 100644 client/src/protoFleet/features/settings/index.ts create mode 100644 client/src/protoFleet/features/settings/utils/formatRole.test.ts create mode 100644 client/src/protoFleet/features/settings/utils/formatRole.ts create mode 100644 client/src/protoFleet/features/settings/utils/scheduleDateUtils.test.ts create mode 100644 client/src/protoFleet/features/settings/utils/scheduleDateUtils.ts create mode 100644 client/src/protoFleet/hooks/useDeviceSetListState.ts create mode 100644 client/src/protoFleet/hooks/usePageBackground.ts create mode 100644 client/src/protoFleet/index.html create mode 100644 client/src/protoFleet/main.tsx create mode 100644 client/src/protoFleet/mainWrapper.tsx create mode 100644 client/src/protoFleet/pages/Home/Home.tsx create mode 100644 client/src/protoFleet/pages/Home/index.ts create mode 100644 client/src/protoFleet/router.tsx create mode 100644 client/src/protoFleet/store/hooks/useAuth.ts create mode 100644 client/src/protoFleet/store/hooks/useAuthentication.ts create mode 100644 client/src/protoFleet/store/hooks/useBatch.ts create mode 100644 client/src/protoFleet/store/hooks/useOnboarding.ts create mode 100644 client/src/protoFleet/store/hooks/useUI.ts create mode 100644 client/src/protoFleet/store/index.ts create mode 100644 client/src/protoFleet/store/slices/authSlice.ts create mode 100644 client/src/protoFleet/store/slices/batchSlice.ts create mode 100644 client/src/protoFleet/store/slices/onboardingSlice.ts create mode 100644 client/src/protoFleet/store/slices/uiSlice.test.ts create mode 100644 client/src/protoFleet/store/slices/uiSlice.ts create mode 100644 client/src/protoFleet/store/useFleetStore.test.ts create mode 100644 client/src/protoFleet/store/useFleetStore.ts create mode 100644 client/src/protoFleet/stories/MockedPoolApis.tsx create mode 100644 client/src/protoFleet/utils/crypto.test.ts create mode 100644 client/src/protoFleet/utils/crypto.ts create mode 100644 client/src/protoFleet/utils/minerFilters.test.ts create mode 100644 client/src/protoFleet/utils/minerFilters.ts create mode 100644 client/src/protoOS/api/apiResponseTypes.ts create mode 100644 client/src/protoOS/api/constants.ts create mode 100644 client/src/protoOS/api/defaultPasswordContract.test.ts create mode 100644 client/src/protoOS/api/defaultPasswordContract.ts create mode 100644 client/src/protoOS/api/generatedApi.ts create mode 100644 client/src/protoOS/api/hooks/useCoolingStatus.ts create mode 100644 client/src/protoOS/api/hooks/useCreatePools.ts create mode 100644 client/src/protoOS/api/hooks/useDownloadLogs.test.ts create mode 100644 client/src/protoOS/api/hooks/useDownloadLogs.ts create mode 100644 client/src/protoOS/api/hooks/useEditPool.ts create mode 100644 client/src/protoOS/api/hooks/useErrors.ts create mode 100644 client/src/protoOS/api/hooks/useFirmwareUpdate.ts create mode 100644 client/src/protoOS/api/hooks/useHardware.ts create mode 100644 client/src/protoOS/api/hooks/useHashboardStatus.test.ts create mode 100644 client/src/protoOS/api/hooks/useHashboardStatus.ts create mode 100644 client/src/protoOS/api/hooks/useHashboards.ts create mode 100644 client/src/protoOS/api/hooks/useLocateSystem.test.ts create mode 100644 client/src/protoOS/api/hooks/useLocateSystem.ts create mode 100644 client/src/protoOS/api/hooks/useLogin.test.ts create mode 100644 client/src/protoOS/api/hooks/useLogin.ts create mode 100644 client/src/protoOS/api/hooks/useMiningStart.ts create mode 100644 client/src/protoOS/api/hooks/useMiningStatus.ts create mode 100644 client/src/protoOS/api/hooks/useMiningStop.ts create mode 100644 client/src/protoOS/api/hooks/useMiningTarget.ts create mode 100644 client/src/protoOS/api/hooks/useNetworkInfo.ts create mode 100644 client/src/protoOS/api/hooks/usePassword.test.ts create mode 100644 client/src/protoOS/api/hooks/usePassword.ts create mode 100644 client/src/protoOS/api/hooks/usePoolsInfo.ts create mode 100644 client/src/protoOS/api/hooks/useRefresh.test.ts create mode 100644 client/src/protoOS/api/hooks/useRefresh.ts create mode 100644 client/src/protoOS/api/hooks/useSystemInfo.ts create mode 100644 client/src/protoOS/api/hooks/useSystemLogs.ts create mode 100644 client/src/protoOS/api/hooks/useSystemReboot.ts create mode 100644 client/src/protoOS/api/hooks/useSystemStatus.test.ts create mode 100644 client/src/protoOS/api/hooks/useSystemStatus.ts create mode 100644 client/src/protoOS/api/hooks/useSystemTag.ts create mode 100644 client/src/protoOS/api/hooks/useTelemetry.ts create mode 100644 client/src/protoOS/api/hooks/useTestConnection.ts create mode 100644 client/src/protoOS/api/hooks/useTimeSeries.ts create mode 100644 client/src/protoOS/api/index.ts create mode 100644 client/src/protoOS/components/App/App.test.tsx create mode 100644 client/src/protoOS/components/App/App.tsx create mode 100644 client/src/protoOS/components/App/AuthenticatedShell.tsx create mode 100644 client/src/protoOS/components/App/ErrorCallout.tsx create mode 100644 client/src/protoOS/components/App/FansDetectedDialog.stories.tsx create mode 100644 client/src/protoOS/components/App/FansDetectedDialog.tsx create mode 100644 client/src/protoOS/components/App/WakeCallout.stories.tsx create mode 100644 client/src/protoOS/components/App/WakeCallout.test.tsx create mode 100644 client/src/protoOS/components/App/WakeCallout.tsx create mode 100644 client/src/protoOS/components/App/WarmingUpCallout.stories.tsx create mode 100644 client/src/protoOS/components/App/WarmingUpCallout.tsx create mode 100644 client/src/protoOS/components/App/index.ts create mode 100644 client/src/protoOS/components/AppLayout/AppLayout.tsx create mode 100644 client/src/protoOS/components/AppLayout/index.ts create mode 100644 client/src/protoOS/components/ContentLayout/DefaultContentLayout.tsx create mode 100644 client/src/protoOS/components/ContentLayout/FullScreenContentLayout.tsx create mode 100644 client/src/protoOS/components/ContentLayout/SettingsContentLayout.tsx create mode 100644 client/src/protoOS/components/ContentLayout/types.ts create mode 100644 client/src/protoOS/components/MiningPools/BackupPoolModalWrapper.test.tsx create mode 100644 client/src/protoOS/components/MiningPools/BackupPoolModalWrapper.tsx create mode 100644 client/src/protoOS/components/MiningPools/MiningPools.stories.tsx create mode 100644 client/src/protoOS/components/MiningPools/MiningPools.tsx create mode 100644 client/src/protoOS/components/MiningPools/Pools.test.tsx create mode 100644 client/src/protoOS/components/MiningPools/Pools.tsx create mode 100644 client/src/protoOS/components/MiningPools/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/FloatingNavigation.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/InfoItem.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/IpAddressInfo/IpAddressInfo.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/IpAddressInfo/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/MacAddressInfo/MacAddressInfo.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/MacAddressInfo/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/MinerNameInfo/MinerNameInfo.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/MinerNameInfo/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/VersionInfo/VersionInfo.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/VersionInfo/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/InfoItem/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/Navigation.stories.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/Navigation.test.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/Navigation.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/NavigationItem/NavigationItem.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/NavigationItem/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/NavigationItems/AppNavigationItems.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/NavigationItems/OnboardingNavigationItems.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/NavigationItems/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/NavigationMenu.tsx create mode 100644 client/src/protoOS/components/NavigationMenu/constants.ts create mode 100644 client/src/protoOS/components/NavigationMenu/index.ts create mode 100644 client/src/protoOS/components/NavigationMenu/types.ts create mode 100644 client/src/protoOS/components/NoPoolsCallout/NoPoolsCallout.tsx create mode 100644 client/src/protoOS/components/NoPoolsCallout/index.ts create mode 100644 client/src/protoOS/components/OnboardingHeader/OnboardingHeader.stories.tsx create mode 100644 client/src/protoOS/components/OnboardingHeader/OnboardingHeader.tsx create mode 100644 client/src/protoOS/components/OnboardingHeader/index.ts create mode 100644 client/src/protoOS/components/OnboardingSettingUp/OnboardingSettingUp.stories.tsx create mode 100644 client/src/protoOS/components/OnboardingSettingUp/OnboardingSettingUpWrapper.tsx create mode 100644 client/src/protoOS/components/OnboardingSettingUp/index.ts create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/GlobalActionsPopover.test.tsx create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/GlobalActionsPopover.tsx create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/GlobalActionsWidget.stories.tsx create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/GlobalActionsWidget.test.tsx create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/GlobalActionsWidget.tsx create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/GlobalActionsWidgetWrapper.test.tsx create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/GlobalActionsWidgetWrapper.tsx create mode 100644 client/src/protoOS/components/PageHeader/GlobalActions/index.ts create mode 100644 client/src/protoOS/components/PageHeader/MinerStatus/MinerStatus.stories.tsx create mode 100644 client/src/protoOS/components/PageHeader/MinerStatus/MinerStatus.tsx create mode 100644 client/src/protoOS/components/PageHeader/MinerStatus/MinerStatusWidget.tsx create mode 100644 client/src/protoOS/components/PageHeader/MinerStatus/index.ts create mode 100644 client/src/protoOS/components/PageHeader/PageHeader.stories.tsx create mode 100644 client/src/protoOS/components/PageHeader/PageHeader.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/PoolInfoPopover.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/PoolInfoRow.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/PoolStatus.stories.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/PoolStatus.test.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/PoolStatus.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/PoolStatusWrapper.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/PoolWidget.tsx create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/index.ts create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/types.ts create mode 100644 client/src/protoOS/components/PageHeader/PoolStatus/utility.ts create mode 100644 client/src/protoOS/components/PageHeader/Power/PowerPopover.tsx create mode 100644 client/src/protoOS/components/PageHeader/Power/PowerWidget.stories.tsx create mode 100644 client/src/protoOS/components/PageHeader/Power/PowerWidget.test.tsx create mode 100644 client/src/protoOS/components/PageHeader/Power/PowerWidget.tsx create mode 100644 client/src/protoOS/components/PageHeader/Power/PowerWidgetWrapper.tsx create mode 100644 client/src/protoOS/components/PageHeader/Power/constants.ts create mode 100644 client/src/protoOS/components/PageHeader/Power/index.ts create mode 100644 client/src/protoOS/components/PageHeader/PowerTarget/PowerTarget.tsx create mode 100644 client/src/protoOS/components/PageHeader/PowerTarget/PowerTargetPopover.test.tsx create mode 100644 client/src/protoOS/components/PageHeader/PowerTarget/PowerTargetPopover.tsx create mode 100644 client/src/protoOS/components/PageHeader/PowerTarget/PowerTargetWrapper.tsx create mode 100644 client/src/protoOS/components/PageHeader/PowerTarget/constants.ts create mode 100644 client/src/protoOS/components/PageHeader/PowerTarget/index.ts create mode 100644 client/src/protoOS/components/PageHeader/WidgetWrapper.tsx create mode 100644 client/src/protoOS/components/PageHeader/index.ts create mode 100644 client/src/protoOS/components/Power/EnteringSleepDialog.stories.tsx create mode 100644 client/src/protoOS/components/Power/EnteringSleepDialog.tsx create mode 100644 client/src/protoOS/components/Power/ExportingLogsDialog.tsx create mode 100644 client/src/protoOS/components/Power/RebootingDialog.tsx create mode 100644 client/src/protoOS/components/Power/WakingDialog.stories.tsx create mode 100644 client/src/protoOS/components/Power/WakingDialog.tsx create mode 100644 client/src/protoOS/components/Power/WarnRebootDialog.stories.tsx create mode 100644 client/src/protoOS/components/Power/WarnRebootDialog.tsx create mode 100644 client/src/protoOS/components/Power/WarnSleepDialog.tsx create mode 100644 client/src/protoOS/components/Power/WarnWakeDialog.tsx create mode 100644 client/src/protoOS/components/Power/index.ts create mode 100644 client/src/protoOS/components/StatusModal/StatusModal.stories.tsx create mode 100644 client/src/protoOS/components/StatusModal/StatusModal.tsx create mode 100644 client/src/protoOS/components/StatusModal/hooks/index.ts create mode 100644 client/src/protoOS/components/StatusModal/hooks/useComponentHardware.ts create mode 100644 client/src/protoOS/components/StatusModal/hooks/useComponentTelemetry.ts create mode 100644 client/src/protoOS/components/StatusModal/index.ts create mode 100644 client/src/protoOS/components/StatusModal/types.ts create mode 100644 client/src/protoOS/components/StatusModal/utils.ts create mode 100644 client/src/protoOS/contexts/MinerHostingContext/MinerHostingContext.tsx create mode 100644 client/src/protoOS/contexts/MinerHostingContext/index.ts create mode 100644 client/src/protoOS/contexts/MinerHostingContext/useMinerHosting.ts create mode 100644 client/src/protoOS/features/auth/components/Auth.stories.tsx create mode 100644 client/src/protoOS/features/auth/components/Auth.tsx create mode 100644 client/src/protoOS/features/auth/components/LoginModal/ForgotPassword.tsx create mode 100644 client/src/protoOS/features/auth/components/LoginModal/LoginForm.tsx create mode 100644 client/src/protoOS/features/auth/components/LoginModal/LoginModal.stories.tsx create mode 100644 client/src/protoOS/features/auth/components/LoginModal/LoginModal.test.tsx create mode 100644 client/src/protoOS/features/auth/components/LoginModal/LoginModal.tsx create mode 100644 client/src/protoOS/features/auth/components/LoginModal/ResizeablePanel.tsx create mode 100644 client/src/protoOS/features/auth/components/LoginModal/index.ts create mode 100644 client/src/protoOS/features/auth/components/constants.ts create mode 100644 client/src/protoOS/features/auth/components/index.ts create mode 100644 client/src/protoOS/features/auth/components/style.css create mode 100644 client/src/protoOS/features/auth/components/types.ts create mode 100644 client/src/protoOS/features/diagnostic/components/Card/Card.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/Card/Card.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/Card/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/CardHeader/CardHeader.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/CardHeader/CardHeader.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/CardHeader/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/ComponentSection/ComponentSection.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/ComponentSection/ComponentSection.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/ComponentSection/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/ComponentSelector/ComponentSelector.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/ComponentSelector/constants.ts create mode 100644 client/src/protoOS/features/diagnostic/components/ComponentSelector/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/ComponentSelector/types.ts create mode 100644 client/src/protoOS/features/diagnostic/components/ControlBoardStatusCard/ControlBoardStatusCard.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/ControlBoardStatusCard/ControlBoardStatusCard.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/ControlBoardStatusCard/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/DiagnosticView/DiagnosticView.test.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/DiagnosticView/DiagnosticView.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/DiagnosticView/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/EmptySlotCard/EmptySlotCard.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/EmptySlotCard/EmptySlotCard.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/EmptySlotCard/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/FanStatusCard/FanStatusCard.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/FanStatusCard/FanStatusCard.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/FanStatusCard/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardStatusCard/HashboardStatusCard.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardStatusCard/HashboardStatusCard.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardStatusCard/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicButton.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicChart/AsicChart.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicChart/AsicChartTooltip.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicChart/constants.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicChart/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicChart/types.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicChart/utility.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicPopover.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicPopover.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicPopoverRow.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/AsicPopoverWrapper.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/constants.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/utility.test.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicPopover/utility.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicTable.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicTable.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/AsicTableWrapper.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/constants.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/Asic/utility.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/AsicMetricContext.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/HashboardSelector.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/HashboardTemperature.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/HashboardTemperatureWrapper.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/utility.test.ts create mode 100644 client/src/protoOS/features/diagnostic/components/HashboardTemperature/utility.ts create mode 100644 client/src/protoOS/features/diagnostic/components/LabeledValue/LabeledValue.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/LabeledValue/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/MetadataRow/MetadataRow.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/MetadataRow/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/PsuStatusCard/PsuStatusCard.stories.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/PsuStatusCard/PsuStatusCard.tsx create mode 100644 client/src/protoOS/features/diagnostic/components/PsuStatusCard/index.ts create mode 100644 client/src/protoOS/features/diagnostic/components/index.ts create mode 100644 client/src/protoOS/features/diagnostic/index.ts create mode 100644 client/src/protoOS/features/diagnostic/types.ts create mode 100644 client/src/protoOS/features/firmwareUpdate/components/CheckForUpdate/CheckForUpdate.tsx create mode 100644 client/src/protoOS/features/firmwareUpdate/components/CheckForUpdate/index.ts create mode 100644 client/src/protoOS/features/firmwareUpdate/components/FirmwareUpdateStatus/FirmwareUpdateStatus.stories.tsx create mode 100644 client/src/protoOS/features/firmwareUpdate/components/FirmwareUpdateStatus/FirmwareUpdateStatus.tsx create mode 100644 client/src/protoOS/features/firmwareUpdate/components/FirmwareUpdateStatus/FirmwareUpdateStatusWidget.tsx create mode 100644 client/src/protoOS/features/firmwareUpdate/components/FirmwareUpdateStatus/FirmwareUpdateStatusWrapper.tsx create mode 100644 client/src/protoOS/features/firmwareUpdate/components/FirmwareUpdateStatus/index.ts create mode 100644 client/src/protoOS/features/firmwareUpdate/components/FirmwareUpdateStatusModal/FirmwareUpdateStatusModal.tsx create mode 100644 client/src/protoOS/features/firmwareUpdate/components/FirmwareUpdateStatusModal/index.ts create mode 100644 client/src/protoOS/features/firmwareUpdate/index.ts create mode 100644 client/src/protoOS/features/firmwareUpdate/utility.ts create mode 100644 client/src/protoOS/features/kpis/components/Efficiency/Efficiency.tsx create mode 100644 client/src/protoOS/features/kpis/components/Efficiency/index.ts create mode 100644 client/src/protoOS/features/kpis/components/HashboardSelector/HashboardSelector.stories.tsx create mode 100644 client/src/protoOS/features/kpis/components/HashboardSelector/HashboardSelector.test.tsx create mode 100644 client/src/protoOS/features/kpis/components/HashboardSelector/HashboardSelector.tsx create mode 100644 client/src/protoOS/features/kpis/components/HashboardSelector/index.ts create mode 100644 client/src/protoOS/features/kpis/components/Hashrate/Hashrate.tsx create mode 100644 client/src/protoOS/features/kpis/components/Hashrate/index.ts create mode 100644 client/src/protoOS/features/kpis/components/KpiLayout/KpiLayout.tsx create mode 100644 client/src/protoOS/features/kpis/components/KpiLayout/index.ts create mode 100644 client/src/protoOS/features/kpis/components/KpiLineChart/KpiLineChart.test.tsx create mode 100644 client/src/protoOS/features/kpis/components/KpiLineChart/KpiLineChart.tsx create mode 100644 client/src/protoOS/features/kpis/components/KpiLineChart/index.ts create mode 100644 client/src/protoOS/features/kpis/components/KpiLineChart/stories/KpiLineChart.stories.tsx create mode 100644 client/src/protoOS/features/kpis/components/KpiLineChart/stories/mocks.ts create mode 100644 client/src/protoOS/features/kpis/components/PowerUsage/PowerUsage.tsx create mode 100644 client/src/protoOS/features/kpis/components/PowerUsage/index.ts create mode 100644 client/src/protoOS/features/kpis/components/TabMenu/TabMenuWrapper.tsx create mode 100644 client/src/protoOS/features/kpis/components/TabMenu/index.ts create mode 100644 client/src/protoOS/features/kpis/components/Temperature/Temperature.tsx create mode 100644 client/src/protoOS/features/kpis/components/Temperature/index.ts create mode 100644 client/src/protoOS/features/kpis/constants.ts create mode 100644 client/src/protoOS/features/kpis/hooks/index.ts create mode 100644 client/src/protoOS/features/kpis/hooks/useAsicColor.ts create mode 100644 client/src/protoOS/features/kpis/hooks/utility.ts create mode 100644 client/src/protoOS/features/kpis/index.ts create mode 100644 client/src/protoOS/features/kpis/types.ts create mode 100644 client/src/protoOS/features/kpis/utility.ts create mode 100644 client/src/protoOS/features/onboarding/components/Authentication/Authentication.test.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Authentication/Authentication.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Authentication/index.ts create mode 100644 client/src/protoOS/features/onboarding/components/MiningPool/MiningPool.test.tsx create mode 100644 client/src/protoOS/features/onboarding/components/MiningPool/MiningPool.tsx create mode 100644 client/src/protoOS/features/onboarding/components/MiningPool/index.ts create mode 100644 client/src/protoOS/features/onboarding/components/Network/Network.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Network/index.ts create mode 100644 client/src/protoOS/features/onboarding/components/NoFansDetectedDialog/NoFansDetectedDialog.stories.tsx create mode 100644 client/src/protoOS/features/onboarding/components/NoFansDetectedDialog/NoFansDetectedDialog.tsx create mode 100644 client/src/protoOS/features/onboarding/components/NoFansDetectedDialog/index.ts create mode 100644 client/src/protoOS/features/onboarding/components/Onboarding/Onboarding.stories.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Onboarding/Onboarding.test.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Onboarding/Onboarding.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Onboarding/index.ts create mode 100644 client/src/protoOS/features/onboarding/components/Verify/Verify.stories.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Verify/Verify.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Verify/VerifyWrapper.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Verify/index.ts create mode 100644 client/src/protoOS/features/onboarding/components/Welcome/Welcome.tsx create mode 100644 client/src/protoOS/features/onboarding/components/Welcome/index.ts create mode 100644 client/src/protoOS/features/onboarding/index.ts create mode 100644 client/src/protoOS/features/settings/components/Authentication/Authentication.test.tsx create mode 100644 client/src/protoOS/features/settings/components/Authentication/Authentication.tsx create mode 100644 client/src/protoOS/features/settings/components/Authentication/index.ts create mode 100644 client/src/protoOS/features/settings/components/Cooling/Cooling.tsx create mode 100644 client/src/protoOS/features/settings/components/Cooling/InfoModal.stories.tsx create mode 100644 client/src/protoOS/features/settings/components/Cooling/InfoModal.tsx create mode 100644 client/src/protoOS/features/settings/components/Cooling/constants.ts create mode 100644 client/src/protoOS/features/settings/components/Cooling/index.ts create mode 100644 client/src/protoOS/features/settings/components/General/General.tsx create mode 100644 client/src/protoOS/features/settings/components/General/MinerSystemTagEditModal.stories.tsx create mode 100644 client/src/protoOS/features/settings/components/General/MinerSystemTagEditModal.test.tsx create mode 100644 client/src/protoOS/features/settings/components/General/MinerSystemTagEditModal.tsx create mode 100644 client/src/protoOS/features/settings/components/General/index.ts create mode 100644 client/src/protoOS/features/settings/components/Hardware/Hardware.test.tsx create mode 100644 client/src/protoOS/features/settings/components/Hardware/Hardware.tsx create mode 100644 client/src/protoOS/features/settings/components/Hardware/constants.ts create mode 100644 client/src/protoOS/features/settings/components/Hardware/index.ts create mode 100644 client/src/protoOS/features/settings/components/Hardware/utility.ts create mode 100644 client/src/protoOS/features/settings/components/MiningPools/MiningPools.tsx create mode 100644 client/src/protoOS/features/settings/components/MiningPools/constants.ts create mode 100644 client/src/protoOS/features/settings/components/MiningPools/index.ts create mode 100644 client/src/protoOS/features/settings/index.ts create mode 100644 client/src/protoOS/hooks/status/constants.ts create mode 100644 client/src/protoOS/hooks/status/index.ts create mode 100644 client/src/protoOS/hooks/status/useComponentDisplayName.ts create mode 100644 client/src/protoOS/hooks/status/useComponentStatusTitle.ts create mode 100644 client/src/protoOS/hooks/status/useMinerStatusCircle.ts create mode 100644 client/src/protoOS/hooks/status/useMinerStatusSummary.ts create mode 100644 client/src/protoOS/hooks/status/useMinerStatusTitle.ts create mode 100644 client/src/protoOS/hooks/useWakeMiner/index.ts create mode 100644 client/src/protoOS/hooks/useWakeMiner/useWakeMiner.ts create mode 100644 client/src/protoOS/index.html create mode 100644 client/src/protoOS/main.tsx create mode 100644 client/src/protoOS/mainWrapper.tsx create mode 100644 client/src/protoOS/pages/MinerLogs/LogBadges.tsx create mode 100644 client/src/protoOS/pages/MinerLogs/Logs.stories.tsx create mode 100644 client/src/protoOS/pages/MinerLogs/Logs.test.ts create mode 100644 client/src/protoOS/pages/MinerLogs/Logs.tsx create mode 100644 client/src/protoOS/pages/MinerLogs/LogsWrapper.tsx create mode 100644 client/src/protoOS/pages/MinerLogs/constants.ts create mode 100644 client/src/protoOS/pages/MinerLogs/index.ts create mode 100644 client/src/protoOS/pages/MinerLogs/types.ts create mode 100644 client/src/protoOS/pages/MinerLogs/utility.ts create mode 100644 client/src/protoOS/routeAuth.ts create mode 100644 client/src/protoOS/router.tsx create mode 100644 client/src/protoOS/store/README.md create mode 100644 client/src/protoOS/store/hooks/index.ts create mode 100644 client/src/protoOS/store/hooks/useAuth.test.ts create mode 100644 client/src/protoOS/store/hooks/useAuth.ts create mode 100644 client/src/protoOS/store/hooks/useAuthRetry.test.ts create mode 100644 client/src/protoOS/store/hooks/useAuthRetry.ts create mode 100644 client/src/protoOS/store/hooks/useHardware.ts create mode 100644 client/src/protoOS/store/hooks/useMiner.ts create mode 100644 client/src/protoOS/store/hooks/useMinerStatus.ts create mode 100644 client/src/protoOS/store/hooks/useMiningTarget.ts create mode 100644 client/src/protoOS/store/hooks/useNetworkInfo.ts create mode 100644 client/src/protoOS/store/hooks/usePools.ts create mode 100644 client/src/protoOS/store/hooks/useSystemInfo.ts create mode 100644 client/src/protoOS/store/hooks/useTelemetry.ts create mode 100644 client/src/protoOS/store/hooks/useUI.ts create mode 100644 client/src/protoOS/store/index.ts create mode 100644 client/src/protoOS/store/slices/authSlice.ts create mode 100644 client/src/protoOS/store/slices/hardwareSlice.ts create mode 100644 client/src/protoOS/store/slices/minerStatusSlice.ts create mode 100644 client/src/protoOS/store/slices/miningTargetSlice.ts create mode 100644 client/src/protoOS/store/slices/networkInfoSlice.ts create mode 100644 client/src/protoOS/store/slices/poolsSlice.ts create mode 100644 client/src/protoOS/store/slices/systemInfoSlice.ts create mode 100644 client/src/protoOS/store/slices/telemetrySlice.ts create mode 100644 client/src/protoOS/store/slices/uiSlice.ts create mode 100644 client/src/protoOS/store/types.ts create mode 100644 client/src/protoOS/store/useMinerStore.ts create mode 100644 client/src/protoOS/store/utils/coolingUtils.ts create mode 100644 client/src/protoOS/store/utils/errorTransformer.test.ts create mode 100644 client/src/protoOS/store/utils/errorTransformer.ts create mode 100644 client/src/protoOS/store/utils/getAsicId.ts create mode 100644 client/src/protoOS/store/utils/getAsicName.ts create mode 100644 client/src/protoOS/store/utils/telemetryUtils.ts create mode 100644 client/src/shared/assets/icons/Activity.tsx create mode 100644 client/src/shared/assets/icons/Alert.tsx create mode 100644 client/src/shared/assets/icons/ArrowDown.tsx create mode 100644 client/src/shared/assets/icons/ArrowLeftCompact.tsx create mode 100644 client/src/shared/assets/icons/ArrowRight.tsx create mode 100644 client/src/shared/assets/icons/ArrowUp.tsx create mode 100644 client/src/shared/assets/icons/Asic.tsx create mode 100644 client/src/shared/assets/icons/BankAccount.tsx create mode 100644 client/src/shared/assets/icons/Bitcoin.tsx create mode 100644 client/src/shared/assets/icons/C1Chip.tsx create mode 100644 client/src/shared/assets/icons/Calendar.tsx create mode 100644 client/src/shared/assets/icons/Checkmark.tsx create mode 100644 client/src/shared/assets/icons/ChevronDown.tsx create mode 100644 client/src/shared/assets/icons/ChevronUpDown.tsx create mode 100644 client/src/shared/assets/icons/Circle.tsx create mode 100644 client/src/shared/assets/icons/ConcentricCircles.tsx create mode 100644 client/src/shared/assets/icons/ControlBoard.tsx create mode 100644 client/src/shared/assets/icons/Copy.tsx create mode 100644 client/src/shared/assets/icons/Curtail.tsx create mode 100644 client/src/shared/assets/icons/Dismiss.tsx create mode 100644 client/src/shared/assets/icons/DismissCircle.tsx create mode 100644 client/src/shared/assets/icons/DismissCircleDark.tsx create mode 100644 client/src/shared/assets/icons/DismissTiny.tsx create mode 100644 client/src/shared/assets/icons/Download.tsx create mode 100644 client/src/shared/assets/icons/Edit.tsx create mode 100644 client/src/shared/assets/icons/Efficiency.tsx create mode 100644 client/src/shared/assets/icons/Ellipsis.tsx create mode 100644 client/src/shared/assets/icons/Eye.tsx create mode 100644 client/src/shared/assets/icons/Fan.tsx create mode 100644 client/src/shared/assets/icons/FanIndicator.tsx create mode 100644 client/src/shared/assets/icons/FanIndicatorV2.tsx create mode 100644 client/src/shared/assets/icons/Fleet.tsx create mode 100644 client/src/shared/assets/icons/FleetWordmark.tsx create mode 100644 client/src/shared/assets/icons/Globe.tsx create mode 100644 client/src/shared/assets/icons/Graph.tsx create mode 100644 client/src/shared/assets/icons/Grip.test.tsx create mode 100644 client/src/shared/assets/icons/Grip.tsx create mode 100644 client/src/shared/assets/icons/Groups.tsx create mode 100644 client/src/shared/assets/icons/Hashboard.tsx create mode 100644 client/src/shared/assets/icons/HashboardIndicator.tsx create mode 100644 client/src/shared/assets/icons/HashboardIndicatorV2.tsx create mode 100644 client/src/shared/assets/icons/Hashrate.tsx create mode 100644 client/src/shared/assets/icons/Home.tsx create mode 100644 client/src/shared/assets/icons/Immersion.tsx create mode 100644 client/src/shared/assets/icons/Info.tsx create mode 100644 client/src/shared/assets/icons/InfoInverted.tsx create mode 100644 client/src/shared/assets/icons/InteractiveIcon.tsx create mode 100644 client/src/shared/assets/icons/LEDIndicator.tsx create mode 100644 client/src/shared/assets/icons/Lightning.tsx create mode 100644 client/src/shared/assets/icons/LightningAlt.tsx create mode 100644 client/src/shared/assets/icons/Lock.tsx create mode 100644 client/src/shared/assets/icons/Logo.tsx create mode 100644 client/src/shared/assets/icons/LogoAlt.tsx create mode 100644 client/src/shared/assets/icons/Logs.tsx create mode 100644 client/src/shared/assets/icons/Menu.tsx create mode 100644 client/src/shared/assets/icons/MiningPools.tsx create mode 100644 client/src/shared/assets/icons/Minus.tsx create mode 100644 client/src/shared/assets/icons/Notification.tsx create mode 100644 client/src/shared/assets/icons/PartialCheckmark.tsx create mode 100644 client/src/shared/assets/icons/Pause.tsx create mode 100644 client/src/shared/assets/icons/Play.tsx create mode 100644 client/src/shared/assets/icons/Plus.tsx create mode 100644 client/src/shared/assets/icons/Power.tsx create mode 100644 client/src/shared/assets/icons/PsuIndicator.tsx create mode 100644 client/src/shared/assets/icons/PsuIndicatorV2.tsx create mode 100644 client/src/shared/assets/icons/Question.tsx create mode 100644 client/src/shared/assets/icons/Racks.tsx create mode 100644 client/src/shared/assets/icons/Reboot.tsx create mode 100644 client/src/shared/assets/icons/Rectangle.tsx create mode 100644 client/src/shared/assets/icons/Repair.tsx create mode 100644 client/src/shared/assets/icons/Settings.tsx create mode 100644 client/src/shared/assets/icons/SettingsSolid.tsx create mode 100644 client/src/shared/assets/icons/Slider.tsx create mode 100644 client/src/shared/assets/icons/Speedometer.tsx create mode 100644 client/src/shared/assets/icons/Stop.tsx create mode 100644 client/src/shared/assets/icons/Success.tsx create mode 100644 client/src/shared/assets/icons/Terminal.tsx create mode 100644 client/src/shared/assets/icons/ThemeDark.tsx create mode 100644 client/src/shared/assets/icons/ThemeLight.tsx create mode 100644 client/src/shared/assets/icons/ThemeSystem.tsx create mode 100644 client/src/shared/assets/icons/Trash.tsx create mode 100644 client/src/shared/assets/icons/Triangle.tsx create mode 100644 client/src/shared/assets/icons/Unpair.tsx create mode 100644 client/src/shared/assets/icons/constants.ts create mode 100644 client/src/shared/assets/icons/icons.stories.tsx create mode 100644 client/src/shared/assets/icons/index.tsx create mode 100644 client/src/shared/assets/icons/types.ts create mode 100644 client/src/shared/assets/images/ProtoRig.png create mode 100644 client/src/shared/assets/images/ProtoRig_2x.png create mode 100644 client/src/shared/assets/images/miner.png create mode 100644 client/src/shared/components/Animation/AnimatedDotsBackground.stories.tsx create mode 100644 client/src/shared/components/Animation/AnimatedDotsBackground.tsx create mode 100644 client/src/shared/components/Animation/index.ts create mode 100644 client/src/shared/components/Animation/style.css create mode 100644 client/src/shared/components/AsicTablePreview/AsicTablePreview.stories.tsx create mode 100644 client/src/shared/components/AsicTablePreview/AsicTablePreview.test.tsx create mode 100644 client/src/shared/components/AsicTablePreview/AsicTablePreview.tsx create mode 100644 client/src/shared/components/AsicTablePreview/index.ts create mode 100644 client/src/shared/components/AsicTablePreview/types.ts create mode 100644 client/src/shared/components/BackgroundImage/BackgroundImage.stories.tsx create mode 100644 client/src/shared/components/BackgroundImage/BackgroundImage.tsx create mode 100644 client/src/shared/components/BackgroundImage/index.ts create mode 100644 client/src/shared/components/BuildVersionInfo/BuildVersionInfo.tsx create mode 100644 client/src/shared/components/BuildVersionInfo/index.ts create mode 100644 client/src/shared/components/Button/Button.stories.tsx create mode 100644 client/src/shared/components/Button/Button.test.tsx create mode 100644 client/src/shared/components/Button/Button.tsx create mode 100644 client/src/shared/components/Button/constants.ts create mode 100644 client/src/shared/components/Button/index.ts create mode 100644 client/src/shared/components/ButtonGroup/ButtonDivider.tsx create mode 100644 client/src/shared/components/ButtonGroup/ButtonGroup.stories.tsx create mode 100644 client/src/shared/components/ButtonGroup/ButtonGroup.tsx create mode 100644 client/src/shared/components/ButtonGroup/constants.ts create mode 100644 client/src/shared/components/ButtonGroup/index.ts create mode 100644 client/src/shared/components/ButtonGroup/types.ts create mode 100644 client/src/shared/components/ButtonGroup/utility.ts create mode 100644 client/src/shared/components/Callout/Callout.stories.tsx create mode 100644 client/src/shared/components/Callout/Callout.tsx create mode 100644 client/src/shared/components/Callout/DismissibleCalloutWrapper.tsx create mode 100644 client/src/shared/components/Callout/constants.ts create mode 100644 client/src/shared/components/Callout/index.ts create mode 100644 client/src/shared/components/Card/Card.stories.tsx create mode 100644 client/src/shared/components/Card/Card.tsx create mode 100644 client/src/shared/components/Card/constants.ts create mode 100644 client/src/shared/components/Card/index.ts create mode 100644 client/src/shared/components/Chart/AxisTick.tsx create mode 100644 client/src/shared/components/Chart/ChartWrapper.tsx create mode 100644 client/src/shared/components/Chart/LineCursor.tsx create mode 100644 client/src/shared/components/Chart/LineDot.tsx create mode 100644 client/src/shared/components/Chart/TimeXAxisTick.test.tsx create mode 100644 client/src/shared/components/Chart/TimeXAxisTick.tsx create mode 100644 client/src/shared/components/Chart/constants.ts create mode 100644 client/src/shared/components/Chart/index.ts create mode 100644 client/src/shared/components/Chart/utility.ts create mode 100644 client/src/shared/components/Checkbox/Checkbox.stories.tsx create mode 100644 client/src/shared/components/Checkbox/Checkbox.tsx create mode 100644 client/src/shared/components/Checkbox/index.ts create mode 100644 client/src/shared/components/Chip/Chip.stories.tsx create mode 100644 client/src/shared/components/Chip/Chip.test.tsx create mode 100644 client/src/shared/components/Chip/Chip.tsx create mode 100644 client/src/shared/components/Chip/index.ts create mode 100644 client/src/shared/components/CompositionBar/CompositionBar.stories.tsx create mode 100644 client/src/shared/components/CompositionBar/CompositionBar.test.tsx create mode 100644 client/src/shared/components/CompositionBar/CompositionBar.tsx create mode 100644 client/src/shared/components/CompositionBar/constants.ts create mode 100644 client/src/shared/components/CompositionBar/index.ts create mode 100644 client/src/shared/components/CompositionBar/types.ts create mode 100644 client/src/shared/components/ContentHeader/ContentHeader.tsx create mode 100644 client/src/shared/components/ContentHeader/index.ts create mode 100644 client/src/shared/components/DataNullState/DataNullState.stories.tsx create mode 100644 client/src/shared/components/DataNullState/DataNullState.tsx create mode 100644 client/src/shared/components/DataNullState/index.ts create mode 100644 client/src/shared/components/DatePicker/Calendar.tsx create mode 100644 client/src/shared/components/DatePicker/DatePicker.stories.tsx create mode 100644 client/src/shared/components/DatePicker/DatePicker.test.tsx create mode 100644 client/src/shared/components/DatePicker/DatePicker.tsx create mode 100644 client/src/shared/components/DatePicker/DatePickerField.test.tsx create mode 100644 client/src/shared/components/DatePicker/DatePickerField.tsx create mode 100644 client/src/shared/components/DatePicker/DatePickerInput.tsx create mode 100644 client/src/shared/components/DatePicker/PresetList.tsx create mode 100644 client/src/shared/components/DatePicker/constants.ts create mode 100644 client/src/shared/components/DatePicker/index.ts create mode 100644 client/src/shared/components/DatePicker/types.ts create mode 100644 client/src/shared/components/DatePicker/utils.ts create mode 100644 client/src/shared/components/Dialog/Dialog.stories.tsx create mode 100644 client/src/shared/components/Dialog/Dialog.tsx create mode 100644 client/src/shared/components/Dialog/DialogIcon.test.tsx create mode 100644 client/src/shared/components/Dialog/DialogIcon.tsx create mode 100644 client/src/shared/components/Dialog/index.ts create mode 100644 client/src/shared/components/Divider/Divider.tsx create mode 100644 client/src/shared/components/Divider/index.ts create mode 100644 client/src/shared/components/DurationSelector/DurationSelector.stories.tsx create mode 100644 client/src/shared/components/DurationSelector/DurationSelector.tsx create mode 100644 client/src/shared/components/DurationSelector/constants.test.ts create mode 100644 client/src/shared/components/DurationSelector/constants.ts create mode 100644 client/src/shared/components/DurationSelector/index.ts create mode 100644 client/src/shared/components/DurationSelector/types.ts create mode 100644 client/src/shared/components/EfficiencyValue/EfficiencyValue.tsx create mode 100644 client/src/shared/components/EfficiencyValue/index.ts create mode 100644 client/src/shared/components/EmptyValue/EmptyValue.tsx create mode 100644 client/src/shared/components/EmptyValue/index.ts create mode 100644 client/src/shared/components/ErrorBoundary/DefaultErrorFallback.tsx create mode 100644 client/src/shared/components/ErrorBoundary/ErrorBoundary.stories.tsx create mode 100644 client/src/shared/components/ErrorBoundary/ErrorBoundary.test.tsx create mode 100644 client/src/shared/components/ErrorBoundary/ErrorBoundary.tsx create mode 100644 client/src/shared/components/ErrorBoundary/index.ts create mode 100644 client/src/shared/components/FanValue/FanValue.tsx create mode 100644 client/src/shared/components/FanValue/index.ts create mode 100644 client/src/shared/components/FileSizeValue/FileSizeValue.tsx create mode 100644 client/src/shared/components/FileSizeValue/formatFileSize.ts create mode 100644 client/src/shared/components/FileSizeValue/index.ts create mode 100644 client/src/shared/components/FleetDown/FleetDown.stories.tsx create mode 100644 client/src/shared/components/FleetDown/FleetDown.test.tsx create mode 100644 client/src/shared/components/FleetDown/FleetDown.tsx create mode 100644 client/src/shared/components/FleetDown/index.ts create mode 100644 client/src/shared/components/HashRateValue/HashRateValue.tsx create mode 100644 client/src/shared/components/HashRateValue/index.ts create mode 100644 client/src/shared/components/Header/Header.stories.tsx create mode 100644 client/src/shared/components/Header/Header.test.tsx create mode 100644 client/src/shared/components/Header/Header.tsx create mode 100644 client/src/shared/components/Header/index.ts create mode 100644 client/src/shared/components/Input/Input.stories.tsx create mode 100644 client/src/shared/components/Input/Input.test.tsx create mode 100644 client/src/shared/components/Input/Input.tsx create mode 100644 client/src/shared/components/Input/index.ts create mode 100644 client/src/shared/components/Input/useValueWidth.tsx create mode 100644 client/src/shared/components/LatencyValue/LatencyValue.tsx create mode 100644 client/src/shared/components/LatencyValue/index.ts create mode 100644 client/src/shared/components/LineChart/LineChart.test.tsx create mode 100644 client/src/shared/components/LineChart/LineChart.tsx create mode 100644 client/src/shared/components/LineChart/Tooltip/Tooltip.test.tsx create mode 100644 client/src/shared/components/LineChart/Tooltip/Tooltip.tsx create mode 100644 client/src/shared/components/LineChart/Tooltip/TooltipItem.tsx create mode 100644 client/src/shared/components/LineChart/Tooltip/index.ts create mode 100644 client/src/shared/components/LineChart/constants.ts create mode 100644 client/src/shared/components/LineChart/index.ts create mode 100644 client/src/shared/components/LineChart/types.ts create mode 100644 client/src/shared/components/List/Filters/ButtonFilter.tsx create mode 100644 client/src/shared/components/List/Filters/DropdownFilter.stories.tsx create mode 100644 client/src/shared/components/List/Filters/DropdownFilter.tsx create mode 100644 client/src/shared/components/List/Filters/DropdownFilterPopover.tsx create mode 100644 client/src/shared/components/List/Filters/Filters.stories.tsx create mode 100644 client/src/shared/components/List/Filters/Filters.test.tsx create mode 100644 client/src/shared/components/List/Filters/Filters.tsx create mode 100644 client/src/shared/components/List/Filters/index.tsx create mode 100644 client/src/shared/components/List/Filters/types.ts create mode 100644 client/src/shared/components/List/List.stories.tsx create mode 100644 client/src/shared/components/List/List.test.tsx create mode 100644 client/src/shared/components/List/List.tsx create mode 100644 client/src/shared/components/List/ListActions/ListActions.test.tsx create mode 100644 client/src/shared/components/List/ListActions/ListActions.tsx create mode 100644 client/src/shared/components/List/ListActions/index.ts create mode 100644 client/src/shared/components/List/constants.ts create mode 100644 client/src/shared/components/List/index.ts create mode 100644 client/src/shared/components/List/mocks/colConfig.tsx create mode 100644 client/src/shared/components/List/mocks/data.ts create mode 100644 client/src/shared/components/List/types.ts create mode 100644 client/src/shared/components/MiningPools/PoolForm/PoolForm.tsx create mode 100644 client/src/shared/components/MiningPools/PoolForm/constants.ts create mode 100644 client/src/shared/components/MiningPools/PoolForm/index.ts create mode 100644 client/src/shared/components/MiningPools/PoolModal.stories.tsx create mode 100644 client/src/shared/components/MiningPools/PoolModal.test.tsx create mode 100644 client/src/shared/components/MiningPools/PoolModal.tsx create mode 100644 client/src/shared/components/MiningPools/PoolRow.test.tsx create mode 100644 client/src/shared/components/MiningPools/PoolRow.tsx create mode 100644 client/src/shared/components/MiningPools/WarnBackupPoolDialog/WarnBackupPoolDialog.tsx create mode 100644 client/src/shared/components/MiningPools/WarnBackupPoolDialog/index.ts create mode 100644 client/src/shared/components/MiningPools/WarnDefaultPoolCallout/WarnDefaultPoolCallout.tsx create mode 100644 client/src/shared/components/MiningPools/WarnDefaultPoolCallout/index.ts create mode 100644 client/src/shared/components/MiningPools/constants.ts create mode 100644 client/src/shared/components/MiningPools/types.ts create mode 100644 client/src/shared/components/MiningPools/utility.ts create mode 100644 client/src/shared/components/MiningPools/validation.ts create mode 100644 client/src/shared/components/Modal/Modal.stories.tsx create mode 100644 client/src/shared/components/Modal/Modal.tsx create mode 100644 client/src/shared/components/Modal/ModalSelectAllFooter.tsx create mode 100644 client/src/shared/components/Modal/constants.ts create mode 100644 client/src/shared/components/Modal/index.ts create mode 100644 client/src/shared/components/MorphingPlusMinus/MorphingPlusMinus.tsx create mode 100644 client/src/shared/components/MorphingPlusMinus/index.ts create mode 100644 client/src/shared/components/NamePreview/NamePreview.test.tsx create mode 100644 client/src/shared/components/NamePreview/NamePreview.tsx create mode 100644 client/src/shared/components/NamePreview/PreviewContainer.tsx create mode 100644 client/src/shared/components/NamePreview/index.ts create mode 100644 client/src/shared/components/OnboardingSettingUp/ConfiguringMiningPool.tsx create mode 100644 client/src/shared/components/OnboardingSettingUp/OnboardingSettingUp.tsx create mode 100644 client/src/shared/components/PageOverlay/PageOverlay.tsx create mode 100644 client/src/shared/components/PageOverlay/index.ts create mode 100644 client/src/shared/components/Picture/Picture.stories.tsx create mode 100644 client/src/shared/components/Picture/Picture.tsx create mode 100644 client/src/shared/components/Picture/index.ts create mode 100644 client/src/shared/components/Popover/Popover.stories.tsx create mode 100644 client/src/shared/components/Popover/Popover.tsx create mode 100644 client/src/shared/components/Popover/PopoverContent.tsx create mode 100644 client/src/shared/components/Popover/PopoverContext.tsx create mode 100644 client/src/shared/components/Popover/constants.ts create mode 100644 client/src/shared/components/Popover/index.ts create mode 100644 client/src/shared/components/Popover/style.css create mode 100644 client/src/shared/components/Popover/types.ts create mode 100644 client/src/shared/components/Popover/usePopover.ts create mode 100644 client/src/shared/components/Popover/usePopoverPosition.ts create mode 100644 client/src/shared/components/PowerValue/PowerValue.tsx create mode 100644 client/src/shared/components/PowerValue/index.ts create mode 100644 client/src/shared/components/ProgressCircular/ProgressCircular.stories.tsx create mode 100644 client/src/shared/components/ProgressCircular/ProgressCircular.tsx create mode 100644 client/src/shared/components/ProgressCircular/index.ts create mode 100644 client/src/shared/components/Radio/Radio.stories.tsx create mode 100644 client/src/shared/components/Radio/Radio.tsx create mode 100644 client/src/shared/components/Radio/index.ts create mode 100644 client/src/shared/components/Row/Row.stories.tsx create mode 100644 client/src/shared/components/Row/Row.tsx create mode 100644 client/src/shared/components/Row/index.ts create mode 100644 client/src/shared/components/Search/Search.stories.tsx create mode 100644 client/src/shared/components/Search/Search.tsx create mode 100644 client/src/shared/components/Search/index.ts create mode 100644 client/src/shared/components/SegmentedBarChart/SegmentedBarChart.stories.tsx create mode 100644 client/src/shared/components/SegmentedBarChart/SegmentedBarChart.tsx create mode 100644 client/src/shared/components/SegmentedBarChart/index.ts create mode 100644 client/src/shared/components/SegmentedBarChart/types.ts create mode 100644 client/src/shared/components/SegmentedBarChart/utils.ts create mode 100644 client/src/shared/components/SegmentedControl/Segment.tsx create mode 100644 client/src/shared/components/SegmentedControl/SegmentedControl.stories.tsx create mode 100644 client/src/shared/components/SegmentedControl/SegmentedControl.test.tsx create mode 100644 client/src/shared/components/SegmentedControl/SegmentedControl.tsx create mode 100644 client/src/shared/components/SegmentedControl/index.ts create mode 100644 client/src/shared/components/SegmentedControl/types.ts create mode 100644 client/src/shared/components/Select/Select.tsx create mode 100644 client/src/shared/components/Select/index.ts create mode 100644 client/src/shared/components/SelectRow/SelectRow.stories.tsx create mode 100644 client/src/shared/components/SelectRow/SelectRow.tsx create mode 100644 client/src/shared/components/SelectRow/index.ts create mode 100644 client/src/shared/components/SelectRowList/SelectRow.tsx create mode 100644 client/src/shared/components/SelectRowList/SelectRowList.stories.tsx create mode 100644 client/src/shared/components/SelectRowList/SelectRowList.tsx create mode 100644 client/src/shared/components/SelectRowList/index.ts create mode 100644 client/src/shared/components/Setup/Authentication.stories.tsx create mode 100644 client/src/shared/components/Setup/Authentication.test.tsx create mode 100644 client/src/shared/components/Setup/Authentication.tsx create mode 100644 client/src/shared/components/Setup/BootingUp.stories.tsx create mode 100644 client/src/shared/components/Setup/BootingUp.tsx create mode 100644 client/src/shared/components/Setup/Network.stories.tsx create mode 100644 client/src/shared/components/Setup/Network.tsx create mode 100644 client/src/shared/components/Setup/NetworkDetails.tsx create mode 100644 client/src/shared/components/Setup/OnboardingLayout/OnboardingLayout.tsx create mode 100644 client/src/shared/components/Setup/OnboardingLayout/index.ts create mode 100644 client/src/shared/components/Setup/SetupHeader.stories.tsx create mode 100644 client/src/shared/components/Setup/SetupHeader.tsx create mode 100644 client/src/shared/components/Setup/WelcomeScreen.stories.tsx create mode 100644 client/src/shared/components/Setup/WelcomeScreen.tsx create mode 100644 client/src/shared/components/Setup/authentication.constants.ts create mode 100644 client/src/shared/components/Setup/authentication.types.ts create mode 100644 client/src/shared/components/Setup/index.ts create mode 100644 client/src/shared/components/Setup/miners.constants.ts create mode 100644 client/src/shared/components/Setup/network.constants.ts create mode 100644 client/src/shared/components/Setup/network.types.ts create mode 100644 client/src/shared/components/SkeletonBar/SkeletonBar.stories.tsx create mode 100644 client/src/shared/components/SkeletonBar/SkeletonBar.tsx create mode 100644 client/src/shared/components/SkeletonBar/index.ts create mode 100644 client/src/shared/components/SlotNumber/SlotNumber.tsx create mode 100644 client/src/shared/components/SlotNumber/index.ts create mode 100644 client/src/shared/components/SortIndicator/SortIndicator.test.tsx create mode 100644 client/src/shared/components/SortIndicator/SortIndicator.tsx create mode 100644 client/src/shared/components/SortIndicator/index.ts create mode 100644 client/src/shared/components/Stat/Stat.stories.tsx create mode 100644 client/src/shared/components/Stat/Stat.test.tsx create mode 100644 client/src/shared/components/Stat/Stat.tsx create mode 100644 client/src/shared/components/Stat/constants.ts create mode 100644 client/src/shared/components/Stat/index.ts create mode 100644 client/src/shared/components/Stat/types.ts create mode 100644 client/src/shared/components/Stats/Stats.tsx create mode 100644 client/src/shared/components/Stats/index.ts create mode 100644 client/src/shared/components/StatusCircle/StatusCircle.stories.tsx create mode 100644 client/src/shared/components/StatusCircle/StatusCircle.tsx create mode 100644 client/src/shared/components/StatusCircle/constants.ts create mode 100644 client/src/shared/components/StatusCircle/index.ts create mode 100644 client/src/shared/components/StatusCircle/types.ts create mode 100644 client/src/shared/components/StatusModal/ComponentMetadata.tsx create mode 100644 client/src/shared/components/StatusModal/ComponentStatusModalContent.test.tsx create mode 100644 client/src/shared/components/StatusModal/ComponentStatusModalContent.tsx create mode 100644 client/src/shared/components/StatusModal/ErrorRow.tsx create mode 100644 client/src/shared/components/StatusModal/MinerStatusModalContent.tsx create mode 100644 client/src/shared/components/StatusModal/StatusModal.stories.tsx create mode 100644 client/src/shared/components/StatusModal/StatusModal.test.tsx create mode 100644 client/src/shared/components/StatusModal/StatusModal.tsx create mode 100644 client/src/shared/components/StatusModal/StatusModalLayout.tsx create mode 100644 client/src/shared/components/StatusModal/index.ts create mode 100644 client/src/shared/components/StatusModal/types.ts create mode 100644 client/src/shared/components/StatusModal/utils.ts create mode 100644 client/src/shared/components/StatusOverlay/StatusOverlay.stories.tsx create mode 100644 client/src/shared/components/StatusOverlay/StatusOverlay.tsx create mode 100644 client/src/shared/components/StatusOverlay/index.ts create mode 100644 client/src/shared/components/Switch/Switch.stories.tsx create mode 100644 client/src/shared/components/Switch/Switch.tsx create mode 100644 client/src/shared/components/Switch/index.ts create mode 100644 client/src/shared/components/Tab/Tab.tsx create mode 100644 client/src/shared/components/Tab/Tabs.stories.tsx create mode 100644 client/src/shared/components/Tab/Tabs.tsx create mode 100644 client/src/shared/components/Tab/index.ts create mode 100644 client/src/shared/components/Tab/style.css create mode 100644 client/src/shared/components/TabMenu/Tab/Tab.test.tsx create mode 100644 client/src/shared/components/TabMenu/Tab/Tab.tsx create mode 100644 client/src/shared/components/TabMenu/Tab/index.ts create mode 100644 client/src/shared/components/TabMenu/TabMenu.stories.tsx create mode 100644 client/src/shared/components/TabMenu/TabMenu.test.tsx create mode 100644 client/src/shared/components/TabMenu/TabMenu.tsx create mode 100644 client/src/shared/components/TabMenu/index.ts create mode 100644 client/src/shared/components/TemperatureValue/TemperatureValue.tsx create mode 100644 client/src/shared/components/TemperatureValue/index.ts create mode 100644 client/src/shared/components/Textarea/Textarea.stories.tsx create mode 100644 client/src/shared/components/Textarea/Textarea.test.tsx create mode 100644 client/src/shared/components/Textarea/Textarea.tsx create mode 100644 client/src/shared/components/Textarea/index.ts create mode 100644 client/src/shared/components/Tooltip/Tooltip.stories.tsx create mode 100644 client/src/shared/components/Tooltip/Tooltip.tsx create mode 100644 client/src/shared/components/Tooltip/index.ts create mode 100644 client/src/shared/components/VoltageValue/VoltageValue.tsx create mode 100644 client/src/shared/components/VoltageValue/index.ts create mode 100644 client/src/shared/constants/breakpoints.ts create mode 100644 client/src/shared/constants/cooling.ts create mode 100644 client/src/shared/constants/index.ts create mode 100644 client/src/shared/constants/statuses.ts create mode 100644 client/src/shared/features/preferences/TemperatureUnitsSwitcher.stories.tsx create mode 100644 client/src/shared/features/preferences/TemperatureUnitsSwitcher.test.tsx create mode 100644 client/src/shared/features/preferences/TemperatureUnitsSwitcher.tsx create mode 100644 client/src/shared/features/preferences/ThemeSwitcher.stories.tsx create mode 100644 client/src/shared/features/preferences/ThemeSwitcher.test.tsx create mode 100644 client/src/shared/features/preferences/ThemeSwitcher.tsx create mode 100644 client/src/shared/features/preferences/index.ts create mode 100644 client/src/shared/features/preferences/types.ts create mode 100644 client/src/shared/features/preferences/useApplyTheme.ts create mode 100644 client/src/shared/features/toaster/ToastsObserver.ts create mode 100644 client/src/shared/features/toaster/components/GroupedToaster/GroupedToast.tsx create mode 100644 client/src/shared/features/toaster/components/GroupedToaster/GroupedToaster.stories.tsx create mode 100644 client/src/shared/features/toaster/components/GroupedToaster/GroupedToaster.test.tsx create mode 100644 client/src/shared/features/toaster/components/GroupedToaster/GroupedToaster.tsx create mode 100644 client/src/shared/features/toaster/components/GroupedToaster/index.ts create mode 100644 client/src/shared/features/toaster/components/Toast/Toast.stories.tsx create mode 100644 client/src/shared/features/toaster/components/Toast/Toast.tsx create mode 100644 client/src/shared/features/toaster/components/Toast/index.ts create mode 100644 client/src/shared/features/toaster/components/Toaster/Toaster.stories.tsx create mode 100644 client/src/shared/features/toaster/components/Toaster/Toaster.tsx create mode 100644 client/src/shared/features/toaster/components/Toaster/index.ts create mode 100644 client/src/shared/features/toaster/constants.ts create mode 100644 client/src/shared/features/toaster/index.ts create mode 100644 client/src/shared/features/toaster/types.ts create mode 100644 client/src/shared/hooks/useClickOutside.ts create mode 100644 client/src/shared/hooks/useCssVariable.ts create mode 100644 client/src/shared/hooks/useFloatingPosition.test.ts create mode 100644 client/src/shared/hooks/useFloatingPosition.ts create mode 100644 client/src/shared/hooks/useKeyDown.ts create mode 100644 client/src/shared/hooks/useLocalStorage.ts create mode 100644 client/src/shared/hooks/useMeasure.ts create mode 100644 client/src/shared/hooks/useNavigate.ts create mode 100644 client/src/shared/hooks/useNeedsAttention.test.ts create mode 100644 client/src/shared/hooks/useNeedsAttention.ts create mode 100644 client/src/shared/hooks/usePoll.ts create mode 100644 client/src/shared/hooks/usePreventScroll.ts create mode 100644 client/src/shared/hooks/useReactiveLocalStorage.ts create mode 100644 client/src/shared/hooks/useSlideUpAnimation.ts create mode 100644 client/src/shared/hooks/useStatusSummary/index.ts create mode 100644 client/src/shared/hooks/useStatusSummary/types.ts create mode 100644 client/src/shared/hooks/useStatusSummary/useStatusSummary.test.ts create mode 100644 client/src/shared/hooks/useStatusSummary/useStatusSummary.ts create mode 100644 client/src/shared/hooks/useStatusSummary/utils.ts create mode 100644 client/src/shared/hooks/useStickyState.ts create mode 100644 client/src/shared/hooks/useWindowDimensions.ts create mode 100644 client/src/shared/stories/colors.stories.tsx create mode 100644 client/src/shared/stories/createRefCountedStoryMock.ts create mode 100644 client/src/shared/stories/elevation.stories.tsx create mode 100644 client/src/shared/stories/icons.tsx create mode 100644 client/src/shared/stories/typography.stories.tsx create mode 100644 client/src/shared/styles/fonts.css create mode 100644 client/src/shared/styles/index.css create mode 100644 client/src/shared/styles/theme.css create mode 100644 client/src/shared/styles/theme.test.ts create mode 100644 client/src/shared/utils/backendHealth.test.ts create mode 100644 client/src/shared/utils/backendHealth.ts create mode 100644 client/src/shared/utils/cssUtils.test.ts create mode 100644 client/src/shared/utils/cssUtils.ts create mode 100644 client/src/shared/utils/datetime.test.ts create mode 100644 client/src/shared/utils/datetime.ts create mode 100644 client/src/shared/utils/fleetDownRedirect.test.ts create mode 100644 client/src/shared/utils/fleetDownRedirect.ts create mode 100644 client/src/shared/utils/formatTimestamp.ts create mode 100644 client/src/shared/utils/math.ts create mode 100644 client/src/shared/utils/measurementUtils.ts create mode 100644 client/src/shared/utils/network.test.ts create mode 100644 client/src/shared/utils/network.ts create mode 100644 client/src/shared/utils/networkDiscovery.test.ts create mode 100644 client/src/shared/utils/networkDiscovery.ts create mode 100644 client/src/shared/utils/object.test.ts create mode 100644 client/src/shared/utils/object.ts create mode 100644 client/src/shared/utils/predicate.test.ts create mode 100644 client/src/shared/utils/predicate.ts create mode 100644 client/src/shared/utils/routeUtils.ts create mode 100644 client/src/shared/utils/stringUtils.test.ts create mode 100644 client/src/shared/utils/stringUtils.ts create mode 100644 client/src/shared/utils/utility.test.ts create mode 100644 client/src/shared/utils/utility.ts create mode 100644 client/src/shared/utils/version.ts create mode 100644 client/src/tests/setup.ts create mode 100644 client/src/vite-env.d.ts create mode 100644 client/tsconfig.json create mode 100644 client/tsconfig.node.json create mode 100644 client/vite.config.ts create mode 100644 client/vitePlugins/responsiveImagePlugin.ts create mode 100644 deployment-files/README.md create mode 100644 deployment-files/client/Dockerfile create mode 100644 deployment-files/client/nginx.http.conf create mode 100644 deployment-files/client/nginx.https.conf create mode 100644 deployment-files/docker-compose.protofleet-real-miners-runner.yaml create mode 100644 deployment-files/docker-compose.yaml create mode 100755 deployment-files/install.sh create mode 100755 deployment-files/migrate-data.sh create mode 100755 deployment-files/rollback-migration.sh create mode 100755 deployment-files/run-fleet.sh create mode 100755 deployment-files/scripts/export-influxdb.sh create mode 100755 deployment-files/scripts/export-mysql.sh create mode 100755 deployment-files/scripts/import-postgres.sh create mode 100755 deployment-files/scripts/import-timescaledb.sh create mode 100644 deployment-files/scripts/lib.sh create mode 100644 deployment-files/server/Dockerfile create mode 100755 deployment-files/uninstall.sh create mode 100644 deployment-files/windows/.gitignore create mode 100644 deployment-files/windows/NuGet.Config create mode 100644 deployment-files/windows/PSScriptAnalyzerSettings.psd1 create mode 100644 deployment-files/windows/ProtoFleet.Installer.sln create mode 100644 deployment-files/windows/README.md create mode 100644 deployment-files/windows/build-fleet-installer.ps1 create mode 100644 deployment-files/windows/build-fleet-uninstaller-exe.ps1 create mode 100644 deployment-files/windows/fleet-uninstaller.ps1 create mode 100644 deployment-files/windows/global.json create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/App.xaml create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/App.xaml.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/InstallerApplicationService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/LinuxUserSetupCoordinator.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/MainWindow.xaml create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/MainWindow.xaml.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/ProtoFleet.Installer.App.csproj create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/UiLogSink.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.App/app.manifest create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Abstractions.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/CommandEscaping.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/CommandRequest.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/CommandResult.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/DeploymentResolution.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/EnvFile.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/HostCheckReport.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerCheckpoint.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerContext.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerExitCode.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerOptions.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerOptionsParser.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerOptionsParserException.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerOrchestrator.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerResumeState.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerRunResult.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerStepResult.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerUserActionType.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/InstallerWorkflowRunner.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/LogSinks.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/ProcessCommandRunner.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/ProtoFleet.Installer.Core.csproj create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/ProtocolMode.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/RetryPolicy.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/ScheduledTaskCommandBuilder.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Services/DeploymentResolver.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Services/EnvConfigurator.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Services/NginxConfigurator.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/SetupMode.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/ShellEscaping.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/ConfigureEnvironmentStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/ConfigureNginxStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/DeployComposeStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/PostStartHealthStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/PreflightAndSetupStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/ResolveDeploymentStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/ScheduledTaskStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/Steps/ValidatePluginsStep.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Core/WindowsFeatureCheck.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Windows/JsonFileResumeStateStore.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Windows/ProtoFleet.Installer.Platform.Windows.csproj create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Windows/RunOnceResumeRegistrationService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Windows/ScheduledTaskService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Windows/SystemPrereqService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Windows/WindowsElevationService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/ComposeDeployer.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/DeploymentPreparationService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/DockerReadinessService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/PluginValidator.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/PostStartHealthChecker.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/ProtoFleet.Installer.Platform.Wsl.csproj create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/WslCommandExecutor.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/WslOutputClassifier.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/WslRecoveryService.cs create mode 100644 deployment-files/windows/src/ProtoFleet.Installer.Platform.Wsl/WslSetupService.cs create mode 100644 deployment-files/windows/tests/ProtoFleet.Installer.Tests/CommandEscapingTests.cs create mode 100644 deployment-files/windows/tests/ProtoFleet.Installer.Tests/DeploymentResolverTests.cs create mode 100644 deployment-files/windows/tests/ProtoFleet.Installer.Tests/EnvFileTests.cs create mode 100644 deployment-files/windows/tests/ProtoFleet.Installer.Tests/InstallerOptionsParserTests.cs create mode 100644 deployment-files/windows/tests/ProtoFleet.Installer.Tests/InstallerWorkflowRunnerCancellationTests.cs create mode 100644 deployment-files/windows/tests/ProtoFleet.Installer.Tests/InstallerWorkflowRunnerResumeTests.cs create mode 100644 deployment-files/windows/tests/ProtoFleet.Installer.Tests/ProtoFleet.Installer.Tests.csproj create mode 100644 deployment-files/windows/wsl-keepalive-trials.md create mode 100755 dev.sh create mode 100644 docs/architecture.md create mode 100644 docs/logo.svg create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 hermit-packages/proto-python-gen.hcl create mode 100644 just/go-plugin.just create mode 100644 justfile create mode 100644 lefthook.yml create mode 100644 packages/proto-python-gen/README.md create mode 100755 packages/proto-python-gen/bin/protoc-gen-python-grpc create mode 100644 packages/proto-python-gen/dev-requirements.txt create mode 100644 packages/proto-python-gen/justfile create mode 100644 packages/proto-python-gen/proto-python-gen-0.2.0.tar.gz create mode 100644 packages/proto-python-gen/protoc_gen_python_grpc.py create mode 100644 packages/proto-python-gen/requirements.txt create mode 100755 packages/proto-python-gen/setup.sh create mode 100644 packages/proto-python-gen/tests/test_protos/buf.gen.prefix.yaml create mode 100644 packages/proto-python-gen/tests/test_protos/buf.gen.yaml create mode 100644 packages/proto-python-gen/tests/test_protos/buf.yaml create mode 100644 packages/proto-python-gen/tests/test_protos/test/v1/test.proto create mode 100644 plugin/antminer/.golangci.yaml create mode 100644 plugin/antminer/README.md create mode 100644 plugin/antminer/go.mod create mode 100644 plugin/antminer/go.sum create mode 100644 plugin/antminer/internal/device/device.go create mode 100644 plugin/antminer/internal/device/device_test.go create mode 100644 plugin/antminer/internal/device/errors.go create mode 100644 plugin/antminer/internal/device/errors_test.go create mode 100644 plugin/antminer/internal/driver/driver.go create mode 100644 plugin/antminer/internal/driver/driver_test.go create mode 100644 plugin/antminer/internal/types/types.go create mode 100644 plugin/antminer/justfile create mode 100644 plugin/antminer/main.go create mode 100644 plugin/antminer/pkg/antminer/client.go create mode 100644 plugin/antminer/pkg/antminer/client_test.go create mode 100644 plugin/antminer/pkg/antminer/interface.go create mode 100644 plugin/antminer/pkg/antminer/mocks/mock_client.go create mode 100644 plugin/antminer/pkg/antminer/networking/networking.go create mode 100644 plugin/antminer/pkg/antminer/rpc/mocks/mock_rpc_client.go create mode 100644 plugin/antminer/pkg/antminer/rpc/service.go create mode 100644 plugin/antminer/pkg/antminer/rpc/service_test.go create mode 100644 plugin/antminer/pkg/antminer/rpc/types.go create mode 100644 plugin/antminer/pkg/antminer/web/config.go create mode 100644 plugin/antminer/pkg/antminer/web/digest.go create mode 100644 plugin/antminer/pkg/antminer/web/mocks/mock_web_api_client.go create mode 100644 plugin/antminer/pkg/antminer/web/service.go create mode 100644 plugin/antminer/pkg/antminer/web/service_test.go create mode 100644 plugin/antminer/pkg/antminer/web/type.go create mode 100644 plugin/asicrs/Cargo.lock create mode 100644 plugin/asicrs/Cargo.toml create mode 100644 plugin/asicrs/Dockerfile.build create mode 100644 plugin/asicrs/config.yaml create mode 100644 plugin/asicrs/src/capabilities.rs create mode 100644 plugin/asicrs/src/config.rs create mode 100644 plugin/asicrs/src/device.rs create mode 100644 plugin/asicrs/src/driver.rs create mode 100644 plugin/asicrs/src/main.rs create mode 100644 plugin/example-python/.gitignore create mode 100644 plugin/example-python/Dockerfile.build create mode 100644 plugin/example-python/example_driver/__init__.py create mode 100644 plugin/example-python/example_driver/driver.py create mode 100644 plugin/example-python/main.py create mode 100644 plugin/example-python/pyproject.toml create mode 100644 plugin/example-python/tests/__init__.py create mode 100644 plugin/example-python/tests/test_driver.py create mode 100644 plugin/proto/.golangci.yaml create mode 100644 plugin/proto/README.md create mode 100644 plugin/proto/docs/getting-started.md create mode 100644 plugin/proto/docs/integration-testing.md create mode 100644 plugin/proto/docs/sdk-patterns.md create mode 100644 plugin/proto/go.mod create mode 100644 plugin/proto/go.sum create mode 100644 plugin/proto/internal/device/device.go create mode 100644 plugin/proto/internal/device/device_test.go create mode 100644 plugin/proto/internal/device/errors.go create mode 100644 plugin/proto/internal/device/errors_test.go create mode 100644 plugin/proto/internal/device/types/types.go create mode 100644 plugin/proto/internal/driver/discovery_test.go create mode 100644 plugin/proto/internal/driver/driver.go create mode 100644 plugin/proto/justfile create mode 100644 plugin/proto/main.go create mode 100644 plugin/proto/pkg/proto/client.go create mode 100644 plugin/proto/pkg/proto/client_test.go create mode 100644 plugin/proto/pkg/proto/errors.go create mode 100644 plugin/proto/tests/integration_test.go create mode 100644 plugin/proto/tests/testutils/jwt.go create mode 100644 plugin/proto/tests/unit/plugin_test.go create mode 100644 plugin/virtual/README.md create mode 100644 plugin/virtual/config.json create mode 100644 plugin/virtual/go.mod create mode 100644 plugin/virtual/go.sum create mode 100644 plugin/virtual/internal/config/config.go create mode 100644 plugin/virtual/internal/device/device.go create mode 100644 plugin/virtual/internal/driver/driver.go create mode 100644 plugin/virtual/main.go create mode 100644 plugin/virtual/pkg/virtual/simulator.go create mode 100644 proto-rig-api/README.md create mode 100644 proto-rig-api/VERSION.md create mode 100644 proto-rig-api/grpc/hashboard.proto create mode 100644 proto-rig-api/grpc/hashboard_async.proto create mode 100644 proto-rig-api/grpc/hashboard_cmd.proto create mode 100644 proto-rig-api/grpc/hashboard_cmd_debug.proto create mode 100644 proto-rig-api/grpc/hashboard_cmd_mfgtest.proto create mode 100644 proto-rig-api/grpc/hashboard_log.proto create mode 100644 proto-rig-api/grpc/mfgtool_api.proto create mode 100644 proto-rig-api/grpc/mfgtool_test_commands.proto create mode 100644 proto-rig-api/grpc/miner_command_api.proto create mode 100644 proto-rig-api/grpc/miner_common_api.proto create mode 100644 proto-rig-api/grpc/miner_data_api.proto create mode 100644 proto-rig-api/grpc/miner_debug_api.proto create mode 100644 proto-rig-api/grpc/miner_error_code.proto create mode 100644 proto-rig-api/grpc/miner_fan_api.proto create mode 100644 proto-rig-api/grpc/miner_hb_api.proto create mode 100644 proto-rig-api/grpc/miner_psu_api.proto create mode 100644 proto-rig-api/grpc/miner_psu_test_api.proto create mode 100644 proto-rig-api/grpc/miner_system_api.proto create mode 100644 proto-rig-api/grpc/miner_ui_api.proto create mode 100644 proto-rig-api/openapi/MDK-API.json create mode 100644 proto/activity/v1/activity.proto create mode 100644 proto/apikey/v1/apikey.proto create mode 100644 proto/auth/v1/auth.proto create mode 100644 proto/capabilities/v1/capabilities.proto create mode 100644 proto/collection/v1/collection.proto create mode 100644 proto/common/v1/common.proto create mode 100644 proto/common/v1/cooling.proto create mode 100644 proto/common/v1/device_selector.proto create mode 100644 proto/common/v1/measurement.proto create mode 100644 proto/common/v1/sort.proto create mode 100644 proto/device_set/v1/device_set.proto create mode 100644 proto/errors/v1/errors.proto create mode 100644 proto/fleetmanagement/v1/fleetmanagement.proto create mode 100644 proto/fleetperformance/v1/fleetperformance.proto create mode 100644 proto/foremanimport/v1/foremanimport.proto create mode 100644 proto/minercommand/v1/command.proto create mode 100644 proto/networkinfo/v1/networkinfo.proto create mode 100644 proto/onboarding/v1/onboarding.proto create mode 100644 proto/pairing/v1/pairing.proto create mode 100644 proto/ping/v1/ping.proto create mode 100644 proto/pools/v1/pools.proto create mode 100644 proto/schedule/v1/schedule.proto create mode 100644 proto/telemetry/v1/telemetry.proto create mode 100755 scripts/lefthook-client-format.sh create mode 100755 scripts/lefthook-goimports.sh create mode 100755 scripts/lefthook-lib.sh create mode 100755 scripts/lefthook-proto-buf-lint.sh create mode 100755 scripts/lefthook-python-ruff.sh create mode 100644 scripts/pip-config.sh create mode 100644 sdk/rust/proto-fleet-plugin/Cargo.lock create mode 100644 sdk/rust/proto-fleet-plugin/Cargo.toml create mode 100644 sdk/rust/proto-fleet-plugin/build.rs create mode 100644 sdk/rust/proto-fleet-plugin/src/capabilities.rs create mode 100644 sdk/rust/proto-fleet-plugin/src/errors.rs create mode 100644 sdk/rust/proto-fleet-plugin/src/http_client.rs create mode 100644 sdk/rust/proto-fleet-plugin/src/lib.rs create mode 100644 sdk/rust/proto-fleet-plugin/src/pb.rs create mode 100644 server/.golangci.yaml create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/buf.gen.yaml create mode 100644 server/buf.yaml create mode 100644 server/cmd/fleetd/config.go create mode 100644 server/cmd/fleetd/main.go create mode 100644 server/devtools/seedtelemetry/README.md create mode 100644 server/devtools/seedtelemetry/config.go create mode 100644 server/devtools/seedtelemetry/generator.go create mode 100644 server/devtools/seedtelemetry/inserter.go create mode 100644 server/devtools/seedtelemetry/main.go create mode 100644 server/docker-compose-README.md create mode 100644 server/docker-compose.base.yaml create mode 100644 server/docker-compose.yaml create mode 100644 server/docs/queries.md create mode 100644 server/e2e/README.md create mode 100644 server/e2e/plugin_integration_test.go create mode 100644 server/embed.go create mode 100644 server/fake-antminer/Dockerfile create mode 100644 server/fake-antminer/README.md create mode 100755 server/fake-antminer/docker-entrypoint.sh create mode 100644 server/fake-antminer/go.mod create mode 100644 server/fake-antminer/go.sum create mode 100644 server/fake-antminer/http_handlers.go create mode 100644 server/fake-antminer/main.go create mode 100644 server/fake-antminer/models.go create mode 100644 server/fake-antminer/rpc_handlers.go create mode 100644 server/fake-antminer/sleep_mode_test.go create mode 100644 server/fake-proto-rig/Dockerfile create mode 100644 server/fake-proto-rig/README.md create mode 100644 server/fake-proto-rig/go.mod create mode 100644 server/fake-proto-rig/go.sum create mode 100644 server/fake-proto-rig/main.go create mode 100644 server/fake-proto-rig/models.go create mode 100644 server/fake-proto-rig/rest_api_handler.go create mode 100644 server/fake-proto-rig/rest_api_handler_test.go create mode 100644 server/generated/grpc/activity/v1/activity.pb.go create mode 100644 server/generated/grpc/activity/v1/activityv1connect/activity.connect.go create mode 100644 server/generated/grpc/apikey/v1/apikey.pb.go create mode 100644 server/generated/grpc/apikey/v1/apikeyv1connect/apikey.connect.go create mode 100644 server/generated/grpc/auth/v1/auth.pb.go create mode 100644 server/generated/grpc/auth/v1/authv1connect/auth.connect.go create mode 100644 server/generated/grpc/buf/validate/validate.pb.go create mode 100644 server/generated/grpc/capabilities/v1/capabilities.pb.go create mode 100644 server/generated/grpc/collection/v1/collection.pb.go create mode 100644 server/generated/grpc/collection/v1/collectionv1connect/collection.connect.go create mode 100644 server/generated/grpc/common/v1/common.pb.go create mode 100644 server/generated/grpc/common/v1/cooling.pb.go create mode 100644 server/generated/grpc/common/v1/device_selector.pb.go create mode 100644 server/generated/grpc/common/v1/measurement.pb.go create mode 100644 server/generated/grpc/common/v1/sort.pb.go create mode 100644 server/generated/grpc/device_set/v1/device_set.pb.go create mode 100644 server/generated/grpc/device_set/v1/device_setv1connect/device_set.connect.go create mode 100644 server/generated/grpc/errors/v1/errors.pb.go create mode 100644 server/generated/grpc/errors/v1/errorsv1connect/errors.connect.go create mode 100644 server/generated/grpc/fleetmanagement/v1/fleetmanagement.pb.go create mode 100644 server/generated/grpc/fleetmanagement/v1/fleetmanagementv1connect/fleetmanagement.connect.go create mode 100644 server/generated/grpc/fleetperformance/v1/fleetperformance.pb.go create mode 100644 server/generated/grpc/fleetperformance/v1/fleetperformancev1connect/fleetperformance.connect.go create mode 100644 server/generated/grpc/foremanimport/v1/foremanimport.pb.go create mode 100644 server/generated/grpc/foremanimport/v1/foremanimportv1connect/foremanimport.connect.go create mode 100644 server/generated/grpc/minercommand/v1/command.pb.go create mode 100644 server/generated/grpc/minercommand/v1/minercommandv1connect/command.connect.go create mode 100644 server/generated/grpc/networkinfo/v1/networkinfo.pb.go create mode 100644 server/generated/grpc/networkinfo/v1/networkinfov1connect/networkinfo.connect.go create mode 100644 server/generated/grpc/onboarding/v1/onboarding.pb.go create mode 100644 server/generated/grpc/onboarding/v1/onboardingv1connect/onboarding.connect.go create mode 100644 server/generated/grpc/pairing/v1/pairing.pb.go create mode 100644 server/generated/grpc/pairing/v1/pairingv1connect/pairing.connect.go create mode 100644 server/generated/grpc/ping/v1/ping.pb.go create mode 100644 server/generated/grpc/ping/v1/pingv1connect/ping.connect.go create mode 100644 server/generated/grpc/pools/v1/pools.pb.go create mode 100644 server/generated/grpc/pools/v1/poolsv1connect/pools.connect.go create mode 100644 server/generated/grpc/schedule/v1/schedule.pb.go create mode 100644 server/generated/grpc/schedule/v1/schedulev1connect/schedule.connect.go create mode 100644 server/generated/grpc/telemetry/v1/telemetry.pb.go create mode 100644 server/generated/grpc/telemetry/v1/telemetryv1connect/telemetry.connect.go create mode 100644 server/generated/miner-api/hashboard/hashboard.pb.go create mode 100644 server/generated/miner-api/hashboard_async/hashboard_async.pb.go create mode 100644 server/generated/miner-api/hashboard_cmd/hashboard_cmd.pb.go create mode 100644 server/generated/miner-api/hashboard_cmd_debug/hashboard_cmd_debug.pb.go create mode 100644 server/generated/miner-api/hashboard_cmd_mfgtest/hashboard_cmd_mfgtest.pb.go create mode 100644 server/generated/sqlc/activity.sql.go create mode 100644 server/generated/sqlc/api_key.sql.go create mode 100644 server/generated/sqlc/command.sql.go create mode 100644 server/generated/sqlc/db.go create mode 100644 server/generated/sqlc/device.sql.go create mode 100644 server/generated/sqlc/device_set.sql.go create mode 100644 server/generated/sqlc/discovered_device.sql.go create mode 100644 server/generated/sqlc/errors.sql.go create mode 100644 server/generated/sqlc/miner_credentials.sql.go create mode 100644 server/generated/sqlc/miner_service.sql.go create mode 100644 server/generated/sqlc/models.go create mode 100644 server/generated/sqlc/organization.sql.go create mode 100644 server/generated/sqlc/pool.sql.go create mode 100644 server/generated/sqlc/queue.sql.go create mode 100644 server/generated/sqlc/role.sql.go create mode 100644 server/generated/sqlc/schedule.sql.go create mode 100644 server/generated/sqlc/session.sql.go create mode 100644 server/generated/sqlc/telemetry.sql.go create mode 100644 server/generated/sqlc/user.sql.go create mode 100644 server/generated/sqlc/user_organization.sql.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/http-client.env.json create mode 100644 server/internal/domain/activity/models/models.go create mode 100644 server/internal/domain/activity/service.go create mode 100644 server/internal/domain/activity/service_test.go create mode 100644 server/internal/domain/apikey/service.go create mode 100644 server/internal/domain/apikey/service_test.go create mode 100644 server/internal/domain/auth/password.go create mode 100644 server/internal/domain/auth/service.go create mode 100644 server/internal/domain/auth/service_test.go create mode 100644 server/internal/domain/collection/service.go create mode 100644 server/internal/domain/collection/service_test.go create mode 100644 server/internal/domain/command/capability_checker.go create mode 100644 server/internal/domain/command/capability_checker_test.go create mode 100644 server/internal/domain/command/capability_mapping.go create mode 100644 server/internal/domain/command/config.go create mode 100644 server/internal/domain/command/execution_service.go create mode 100644 server/internal/domain/command/execution_service_credentials_test.go create mode 100644 server/internal/domain/command/execution_service_test.go create mode 100644 server/internal/domain/command/mark_status_integration_test.go create mode 100644 server/internal/domain/command/mocks/mock_miner_getter.go create mode 100644 server/internal/domain/command/reaper_integration_test.go create mode 100644 server/internal/domain/command/service.go create mode 100644 server/internal/domain/command/service_update_mining_pools_test.go create mode 100644 server/internal/domain/command/service_user_auth_test.go create mode 100644 server/internal/domain/command/status_service.go create mode 100644 server/internal/domain/command/status_service_test.go create mode 100644 server/internal/domain/commandtype/enum.go create mode 100644 server/internal/domain/deviceresolver/resolver.go create mode 100644 server/internal/domain/deviceresolver/resolver_test.go create mode 100644 server/internal/domain/diagnostics/closer.go create mode 100644 server/internal/domain/diagnostics/closer_test.go create mode 100644 server/internal/domain/diagnostics/component_type_integration_test.go create mode 100644 server/internal/domain/diagnostics/config.go create mode 100644 server/internal/domain/diagnostics/grouping.go create mode 100644 server/internal/domain/diagnostics/grouping_test.go create mode 100644 server/internal/domain/diagnostics/models/miner_errors.go create mode 100644 server/internal/domain/diagnostics/models/miner_errors_test.go create mode 100644 server/internal/domain/diagnostics/models/pagination.go create mode 100644 server/internal/domain/diagnostics/models/query.go create mode 100644 server/internal/domain/diagnostics/pagination.go create mode 100644 server/internal/domain/diagnostics/pagination_test.go create mode 100644 server/internal/domain/diagnostics/service.go create mode 100644 server/internal/domain/diagnostics/service_test.go create mode 100644 server/internal/domain/diagnostics/watcher.go create mode 100644 server/internal/domain/diagnostics/watcher_test.go create mode 100644 server/internal/domain/fleeterror/error.go create mode 100644 server/internal/domain/fleeterror/error_test.go create mode 100644 server/internal/domain/fleeterror/stacktrace.go create mode 100644 server/internal/domain/fleetimport/importer.go create mode 100644 server/internal/domain/fleetimport/importer_worker_name_test.go create mode 100644 server/internal/domain/fleetimport/types.go create mode 100644 server/internal/domain/fleetmanagement/collection_test.go create mode 100644 server/internal/domain/fleetmanagement/export_csv.go create mode 100644 server/internal/domain/fleetmanagement/export_csv_service_test.go create mode 100644 server/internal/domain/fleetmanagement/export_csv_test.go create mode 100644 server/internal/domain/fleetmanagement/models/models.go create mode 100644 server/internal/domain/fleetmanagement/pool_matching_test.go create mode 100644 server/internal/domain/fleetmanagement/renaming.go create mode 100644 server/internal/domain/fleetmanagement/renaming_test.go create mode 100644 server/internal/domain/fleetmanagement/service.go create mode 100644 server/internal/domain/fleetmanagement/service_test.go create mode 100644 server/internal/domain/fleetmanagement/sort.go create mode 100644 server/internal/domain/fleetmanagement/sort_test.go create mode 100644 server/internal/domain/fleetmanagement/telemetry.go create mode 100644 server/internal/domain/foremanimport/mapper.go create mode 100644 server/internal/domain/foremanimport/mapper_test.go create mode 100644 server/internal/domain/foremanimport/service.go create mode 100644 server/internal/domain/foremanimport/service_test.go create mode 100644 server/internal/domain/ipscanner/config.go create mode 100644 server/internal/domain/ipscanner/integration_test.go create mode 100644 server/internal/domain/ipscanner/mocks/mock_scanner.go create mode 100644 server/internal/domain/ipscanner/models.go create mode 100644 server/internal/domain/ipscanner/network_utils.go create mode 100644 server/internal/domain/ipscanner/network_utils_test.go create mode 100644 server/internal/domain/ipscanner/scanner.go create mode 100644 server/internal/domain/ipscanner/service.go create mode 100644 server/internal/domain/ipscanner/service_test.go create mode 100644 server/internal/domain/miner/cache_test.go create mode 100644 server/internal/domain/miner/dto/command_dto.go create mode 100644 server/internal/domain/miner/dto/firmware.go create mode 100644 server/internal/domain/miner/interfaces/miner.go create mode 100644 server/internal/domain/miner/interfaces/mocks/mock_miner.go create mode 100644 server/internal/domain/miner/models/device.go create mode 100644 server/internal/domain/miner/models/device_test.go create mode 100644 server/internal/domain/miner/models/enums.go create mode 100644 server/internal/domain/miner/service.go create mode 100644 server/internal/domain/miner/service_test.go create mode 100644 server/internal/domain/miner/testhelpers_test.go create mode 100644 server/internal/domain/minerdiscovery/mocks/mock_discoverer.go create mode 100644 server/internal/domain/minerdiscovery/models/models.go create mode 100644 server/internal/domain/minerdiscovery/service.go create mode 100644 server/internal/domain/onboarding/service.go create mode 100644 server/internal/domain/pairing/mocks/mock_pairer.go create mode 100644 server/internal/domain/pairing/mocks/mock_service.go create mode 100644 server/internal/domain/pairing/pairing.go create mode 100644 server/internal/domain/pairing/service.go create mode 100644 server/internal/domain/pairing/service_internal_test.go create mode 100644 server/internal/domain/pairing/service_test.go create mode 100644 server/internal/domain/plugins/capabilities.go create mode 100644 server/internal/domain/plugins/capabilities_test.go create mode 100644 server/internal/domain/plugins/config.go create mode 100644 server/internal/domain/plugins/config_test.go create mode 100644 server/internal/domain/plugins/discoverer.go create mode 100644 server/internal/domain/plugins/discoverer_test.go create mode 100644 server/internal/domain/plugins/manager.go create mode 100644 server/internal/domain/plugins/manager_test.go create mode 100644 server/internal/domain/plugins/mappers/miner_error_mapper.go create mode 100644 server/internal/domain/plugins/mappers/miner_error_mapper_test.go create mode 100644 server/internal/domain/plugins/mappers/sdk_mapper.go create mode 100644 server/internal/domain/plugins/mappers/sdk_mapper_test.go create mode 100644 server/internal/domain/plugins/pairer.go create mode 100644 server/internal/domain/plugins/pairer_test.go create mode 100644 server/internal/domain/plugins/plugin_factory.go create mode 100644 server/internal/domain/plugins/plugin_factory_test.go create mode 100644 server/internal/domain/plugins/plugin_miner.go create mode 100644 server/internal/domain/plugins/plugin_miner_test.go create mode 100644 server/internal/domain/plugins/service.go create mode 100644 server/internal/domain/plugins/service_test.go create mode 100644 server/internal/domain/pools/config.go create mode 100644 server/internal/domain/pools/service.go create mode 100644 server/internal/domain/pools/service_test.go create mode 100644 server/internal/domain/pools/validation.go create mode 100644 server/internal/domain/schedule/cron.go create mode 100644 server/internal/domain/schedule/cron_test.go create mode 100644 server/internal/domain/schedule/mock_command_dispatcher_test.go create mode 100644 server/internal/domain/schedule/processor.go create mode 100644 server/internal/domain/schedule/processor_test.go create mode 100644 server/internal/domain/schedule/service.go create mode 100644 server/internal/domain/schedule/service_test.go create mode 100644 server/internal/domain/session/config.go create mode 100644 server/internal/domain/session/context.go create mode 100644 server/internal/domain/session/mocks/mock_session_store.go create mode 100644 server/internal/domain/session/models.go create mode 100644 server/internal/domain/session/service.go create mode 100644 server/internal/domain/stores/interfaces/activity.go create mode 100644 server/internal/domain/stores/interfaces/apikey.go create mode 100644 server/internal/domain/stores/interfaces/collection.go create mode 100644 server/internal/domain/stores/interfaces/device.go create mode 100644 server/internal/domain/stores/interfaces/discovered_device.go create mode 100644 server/internal/domain/stores/interfaces/error.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_activity_store.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_collection_store.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_device_store.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_discovered_device_store.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_error_store.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_schedule_store.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_transactor.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_user_store.go create mode 100644 server/internal/domain/stores/interfaces/pool.go create mode 100644 server/internal/domain/stores/interfaces/schedule.go create mode 100644 server/internal/domain/stores/interfaces/sort.go create mode 100644 server/internal/domain/stores/interfaces/sort_test.go create mode 100644 server/internal/domain/stores/interfaces/transactor.go create mode 100644 server/internal/domain/stores/interfaces/user.go create mode 100644 server/internal/domain/stores/sqlstores/activity.go create mode 100644 server/internal/domain/stores/sqlstores/activity_test.go create mode 100644 server/internal/domain/stores/sqlstores/apikey.go create mode 100644 server/internal/domain/stores/sqlstores/collection.go create mode 100644 server/internal/domain/stores/sqlstores/collection_cursor.go create mode 100644 server/internal/domain/stores/sqlstores/collection_integration_test.go create mode 100644 server/internal/domain/stores/sqlstores/collection_sort.go create mode 100644 server/internal/domain/stores/sqlstores/collection_sort_test.go create mode 100644 server/internal/domain/stores/sqlstores/device.go create mode 100644 server/internal/domain/stores/sqlstores/device_cursor.go create mode 100644 server/internal/domain/stores/sqlstores/device_cursor_test.go create mode 100644 server/internal/domain/stores/sqlstores/device_filters.go create mode 100644 server/internal/domain/stores/sqlstores/device_filters_test.go create mode 100644 server/internal/domain/stores/sqlstores/device_integration_test.go create mode 100644 server/internal/domain/stores/sqlstores/device_query_fragments.go create mode 100644 server/internal/domain/stores/sqlstores/device_sort.go create mode 100644 server/internal/domain/stores/sqlstores/device_sort_test.go create mode 100644 server/internal/domain/stores/sqlstores/device_test.go create mode 100644 server/internal/domain/stores/sqlstores/discovered_device.go create mode 100644 server/internal/domain/stores/sqlstores/discovered_device_test.go create mode 100644 server/internal/domain/stores/sqlstores/error.go create mode 100644 server/internal/domain/stores/sqlstores/error_integration_test.go create mode 100644 server/internal/domain/stores/sqlstores/pool.go create mode 100644 server/internal/domain/stores/sqlstores/schedule.go create mode 100644 server/internal/domain/stores/sqlstores/session.go create mode 100644 server/internal/domain/stores/sqlstores/sql_connection_manager.go create mode 100644 server/internal/domain/stores/sqlstores/transactor.go create mode 100644 server/internal/domain/stores/sqlstores/transactor_test.go create mode 100644 server/internal/domain/stores/sqlstores/user.go create mode 100644 server/internal/domain/telemetry/broadcaster.go create mode 100644 server/internal/domain/telemetry/config.go create mode 100644 server/internal/domain/telemetry/interfaces.go create mode 100644 server/internal/domain/telemetry/mocks/mock_interfaces.go create mode 100644 server/internal/domain/telemetry/mocks/mock_service.go create mode 100644 server/internal/domain/telemetry/models/data_models.go create mode 100644 server/internal/domain/telemetry/models/device.go create mode 100644 server/internal/domain/telemetry/models/enums.go create mode 100644 server/internal/domain/telemetry/models/queries.go create mode 100644 server/internal/domain/telemetry/models/units.go create mode 100644 server/internal/domain/telemetry/models/units_test.go create mode 100644 server/internal/domain/telemetry/models/v2/component_metrics.go create mode 100644 server/internal/domain/telemetry/models/v2/device_metrics.go create mode 100644 server/internal/domain/telemetry/models/v2/device_metrics_test.go create mode 100644 server/internal/domain/telemetry/models/v2/enums.go create mode 100644 server/internal/domain/telemetry/models/v2/metric_value.go create mode 100644 server/internal/domain/telemetry/scheduler/config.go create mode 100644 server/internal/domain/telemetry/scheduler/errors.go create mode 100644 server/internal/domain/telemetry/scheduler/scheduler.go create mode 100644 server/internal/domain/telemetry/scheduler/scheduler_test.go create mode 100644 server/internal/domain/telemetry/service.go create mode 100644 server/internal/domain/telemetry/service_test.go create mode 100644 server/internal/domain/telemetry/temperature_status.go create mode 100644 server/internal/domain/token/config.go create mode 100644 server/internal/domain/token/service.go create mode 100644 server/internal/domain/token/service_test.go create mode 100644 server/internal/domain/workername/workername.go create mode 100644 server/internal/domain/workername/workername_test.go create mode 100644 server/internal/handlers/activity/handler.go create mode 100644 server/internal/handlers/activity/handler_test.go create mode 100644 server/internal/handlers/apikey/handler.go create mode 100644 server/internal/handlers/apikey/handler_error_sanitization_test.go create mode 100644 server/internal/handlers/apikey/handler_test.go create mode 100644 server/internal/handlers/auth/handler.go create mode 100644 server/internal/handlers/auth/handler_test.go create mode 100644 server/internal/handlers/collection/handler.go create mode 100644 server/internal/handlers/command/handler.go create mode 100644 server/internal/handlers/command/handler_test.go create mode 100644 server/internal/handlers/deviceset/convert.go create mode 100644 server/internal/handlers/deviceset/handler.go create mode 100644 server/internal/handlers/errorquery/conversions.go create mode 100644 server/internal/handlers/errorquery/conversions_test.go create mode 100644 server/internal/handlers/errorquery/handler.go create mode 100644 server/internal/handlers/errorquery/handler_test.go create mode 100644 server/internal/handlers/firmware/chunked.go create mode 100644 server/internal/handlers/firmware/chunked_test.go create mode 100644 server/internal/handlers/firmware/handler.go create mode 100644 server/internal/handlers/firmware/handler_test.go create mode 100644 server/internal/handlers/fleetmanagement/handler.go create mode 100644 server/internal/handlers/fleetmanagement/handler_test.go create mode 100644 server/internal/handlers/foremanimport/handler.go create mode 100644 server/internal/handlers/health/handler.go create mode 100644 server/internal/handlers/interceptors/authentication.go create mode 100644 server/internal/handlers/interceptors/authentication_apikey_error_test.go create mode 100644 server/internal/handlers/interceptors/authentication_test.go create mode 100644 server/internal/handlers/interceptors/config.go create mode 100644 server/internal/handlers/interceptors/config_test.go create mode 100644 server/internal/handlers/interceptors/error_mapping.go create mode 100644 server/internal/handlers/interceptors/error_stack_trace_logging.go create mode 100644 server/internal/handlers/interceptors/request_logging.go create mode 100644 server/internal/handlers/middleware/cors.go create mode 100644 server/internal/handlers/networkinfo/handler.go create mode 100644 server/internal/handlers/onboarding/handler.go create mode 100644 server/internal/handlers/onboarding/handler_test.go create mode 100644 server/internal/handlers/pairing/handler.go create mode 100644 server/internal/handlers/pairing/handler_test.go create mode 100644 server/internal/handlers/ping/handler.go create mode 100644 server/internal/handlers/pools/handler.go create mode 100644 server/internal/handlers/schedule/handler.go create mode 100644 server/internal/handlers/static/handler.go create mode 100644 server/internal/handlers/telemetry/conversion.go create mode 100644 server/internal/handlers/telemetry/conversion_test.go create mode 100644 server/internal/handlers/telemetry/handler.go create mode 100644 server/internal/handlers/telemetry/handler_test.go create mode 100644 server/internal/handlers/telemetry/handler_units_test.go create mode 100644 server/internal/infrastructure/db/config.go create mode 100644 server/internal/infrastructure/db/database_connection.go create mode 100644 server/internal/infrastructure/db/retry.go create mode 100644 server/internal/infrastructure/db/retry_test.go create mode 100644 server/internal/infrastructure/db/with_transaction.go create mode 100644 server/internal/infrastructure/encrypt/config.go create mode 100644 server/internal/infrastructure/encrypt/service.go create mode 100644 server/internal/infrastructure/files/config.go create mode 100644 server/internal/infrastructure/files/firmware.go create mode 100644 server/internal/infrastructure/files/firmware_test.go create mode 100644 server/internal/infrastructure/files/service.go create mode 100644 server/internal/infrastructure/files/service_test.go create mode 100644 server/internal/infrastructure/foreman/client.go create mode 100644 server/internal/infrastructure/foreman/client_test.go create mode 100644 server/internal/infrastructure/foreman/models.go create mode 100644 server/internal/infrastructure/id/id.go create mode 100644 server/internal/infrastructure/logging/logger.go create mode 100644 server/internal/infrastructure/networking/network.go create mode 100644 server/internal/infrastructure/networking/network_test.go create mode 100644 server/internal/infrastructure/queue/config.go create mode 100644 server/internal/infrastructure/queue/interface.go create mode 100644 server/internal/infrastructure/queue/mocks/mock_message_queue.go create mode 100644 server/internal/infrastructure/queue/service.go create mode 100644 server/internal/infrastructure/secrets/README.md create mode 100644 server/internal/infrastructure/secrets/secrets.go create mode 100644 server/internal/infrastructure/secrets/secrets_test.go create mode 100644 server/internal/infrastructure/server/middleware.go create mode 100644 server/internal/infrastructure/stratum/v1/authorization.go create mode 100644 server/internal/infrastructure/stratum/v1/options.go create mode 100644 server/internal/infrastructure/stratum/v1/stratum.go create mode 100644 server/internal/infrastructure/stratum/v1/stratum_test.go create mode 100644 server/internal/infrastructure/stratum/v1/testing/fake_authorize.go create mode 100644 server/internal/infrastructure/stratum/v1/testing/fake_stratum.go create mode 100644 server/internal/infrastructure/stratum/v1/testing/fake_stratum_test.go create mode 100644 server/internal/infrastructure/timescaledb/aggregation_test.go create mode 100644 server/internal/infrastructure/timescaledb/config.go create mode 100644 server/internal/infrastructure/timescaledb/telemetry_store.go create mode 100644 server/internal/infrastructure/timescaledb/telemetry_store_bucket_range_test.go create mode 100644 server/internal/infrastructure/timescaledb/telemetry_store_test.go create mode 100644 server/internal/testutil/config.go create mode 100644 server/internal/testutil/database_queries.go create mode 100644 server/internal/testutil/database_setup.go create mode 100644 server/internal/testutil/infrastructure_provider.go create mode 100644 server/internal/testutil/mock_helpers.go create mode 100644 server/internal/testutil/service_provider.go create mode 100644 server/internal/testutil/test_auth_context.go create mode 100644 server/justfile create mode 100644 server/migrations/000001_initial_setup.down.sql create mode 100644 server/migrations/000001_initial_setup.up.sql create mode 100644 server/migrations/000002_create_core_tables.down.sql create mode 100644 server/migrations/000002_create_core_tables.up.sql create mode 100644 server/migrations/000003_create_device_tables.down.sql create mode 100644 server/migrations/000003_create_device_tables.up.sql create mode 100644 server/migrations/000004_create_pool_and_command_tables.down.sql create mode 100644 server/migrations/000004_create_pool_and_command_tables.up.sql create mode 100644 server/migrations/000005_create_errors_and_telemetry_tables.down.sql create mode 100644 server/migrations/000005_create_errors_and_telemetry_tables.up.sql create mode 100644 server/migrations/000006_create_continuous_aggregates.down.sql create mode 100644 server/migrations/000006_create_continuous_aggregates.up.sql create mode 100644 server/migrations/000007_add_retention_policies.down.sql create mode 100644 server/migrations/000007_add_retention_policies.up.sql create mode 100644 server/migrations/000008_create_status_aggregates.down.sql create mode 100644 server/migrations/000008_create_status_aggregates.up.sql create mode 100644 server/migrations/000009_add_sort_indexes.down.sql create mode 100644 server/migrations/000009_add_sort_indexes.up.sql create mode 100644 server/migrations/000010_add_model_sort_index.down.sql create mode 100644 server/migrations/000010_add_model_sort_index.up.sql create mode 100644 server/migrations/000011_delete_miners_support.down.sql create mode 100644 server/migrations/000011_delete_miners_support.up.sql create mode 100644 server/migrations/000012_create_device_collection_tables.down.sql create mode 100644 server/migrations/000012_create_device_collection_tables.up.sql create mode 100644 server/migrations/000013_add_device_custom_name.down.sql create mode 100644 server/migrations/000013_add_device_custom_name.up.sql create mode 100644 server/migrations/000014_add_driver_name.down.sql create mode 100644 server/migrations/000014_add_driver_name.up.sql create mode 100644 server/migrations/000015_drop_type_column.down.sql create mode 100644 server/migrations/000015_drop_type_column.up.sql create mode 100644 server/migrations/000016_recreate_metrics_aggregates.down.sql create mode 100644 server/migrations/000016_recreate_metrics_aggregates.up.sql create mode 100644 server/migrations/000017_normalize_mac_addresses.down.sql create mode 100644 server/migrations/000017_normalize_mac_addresses.up.sql create mode 100644 server/migrations/000018_add_rack_order_and_cooling.down.sql create mode 100644 server/migrations/000018_add_rack_order_and_cooling.up.sql create mode 100644 server/migrations/000019_add_reaper_index.down.sql create mode 100644 server/migrations/000019_add_reaper_index.up.sql create mode 100644 server/migrations/000020_migrate_proto_grpc_discovery_endpoint.down.sql create mode 100644 server/migrations/000020_migrate_proto_grpc_discovery_endpoint.up.sql create mode 100644 server/migrations/000021_create_activity_log.down.sql create mode 100644 server/migrations/000021_create_activity_log.up.sql create mode 100644 server/migrations/000022_restore_worker_name.down.sql create mode 100644 server/migrations/000022_restore_worker_name.up.sql create mode 100644 server/migrations/000023_normalize_legacy_pool_usernames.down.sql create mode 100644 server/migrations/000023_normalize_legacy_pool_usernames.up.sql create mode 100644 server/migrations/000024_create_schedules.down.sql create mode 100644 server/migrations/000024_create_schedules.up.sql create mode 100644 server/migrations/000025_rename_rack_location_to_zone.down.sql create mode 100644 server/migrations/000025_rename_rack_location_to_zone.up.sql create mode 100644 server/migrations/000026_rename_collection_to_device_set.down.sql create mode 100644 server/migrations/000026_rename_collection_to_device_set.up.sql create mode 100644 server/migrations/000027_migrate_pyasic_to_asicrs.down.sql create mode 100644 server/migrations/000027_migrate_pyasic_to_asicrs.up.sql create mode 100644 server/migrations/000028_create_api_key_table.down.sql create mode 100644 server/migrations/000028_create_api_key_table.up.sql create mode 100644 server/migrations/000029_add_firmware_update_device_statuses.down.sql create mode 100644 server/migrations/000029_add_firmware_update_device_statuses.up.sql create mode 100644 server/migrations/000030_add_worker_name_pool_sync_status.down.sql create mode 100644 server/migrations/000030_add_worker_name_pool_sync_status.up.sql create mode 100644 server/migrations/000031_remove_unused_indexes.down.sql create mode 100644 server/migrations/000031_remove_unused_indexes.up.sql create mode 100644 server/migrations/000032_autovacuum_tuning.down.sql create mode 100644 server/migrations/000032_autovacuum_tuning.up.sql create mode 100644 server/migrations/migration.go create mode 120000 server/miner-protos create mode 100644 server/sdk/v1/README.md create mode 100644 server/sdk/v1/errors.go create mode 100644 server/sdk/v1/errors/types.go create mode 100644 server/sdk/v1/errors/types_test.go create mode 100644 server/sdk/v1/interface.go create mode 100644 server/sdk/v1/mocks/generate.go create mode 100644 server/sdk/v1/mocks/mock_driver.go create mode 100644 server/sdk/v1/pb/buf.yaml create mode 100644 server/sdk/v1/pb/driver.proto create mode 100644 server/sdk/v1/pb/generated/driver.pb.go create mode 100644 server/sdk/v1/pb/generated/driver_grpc.pb.go create mode 100644 server/sdk/v1/plugin.go create mode 100644 server/sdk/v1/plugin_test.go create mode 100644 server/sdk/v1/port_utils.go create mode 100644 server/sdk/v1/python/.gitignore create mode 100644 server/sdk/v1/python/LICENSE create mode 100644 server/sdk/v1/python/README.md create mode 100644 server/sdk/v1/python/justfile create mode 100644 server/sdk/v1/python/proto_fleet_sdk/__init__.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/capabilities.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/error_codes.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/errors.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/generated/__init__.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/generated/pb/__init__.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/generated/pb/driver_pb2.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/generated/pb/driver_pb2.pyi create mode 100644 server/sdk/v1/python/proto_fleet_sdk/generated/pb/driver_pb2_grpc.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/py.typed create mode 100644 server/sdk/v1/python/proto_fleet_sdk/server.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/utils/__init__.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/utils/capability_helpers.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/utils/http_utils.py create mode 100644 server/sdk/v1/python/proto_fleet_sdk/utils/port_utils.py create mode 100644 server/sdk/v1/python/pyproject.toml create mode 100644 server/sdk/v1/python/tests/__init__.py create mode 100644 server/sdk/v1/python/tests/conftest.py create mode 100644 server/sdk/v1/python/tests/integration/__init__.py create mode 100644 server/sdk/v1/python/tests/test_http_utils.py create mode 100644 server/sdk/v1/python/tests/test_utils.py create mode 100644 server/sdk/v1/version.go create mode 100644 server/sqlc.yaml create mode 100644 server/sqlc/queries/activity.sql create mode 100644 server/sqlc/queries/api_key.sql create mode 100644 server/sqlc/queries/command.sql create mode 100644 server/sqlc/queries/device.sql create mode 100644 server/sqlc/queries/device_set.sql create mode 100644 server/sqlc/queries/discovered_device.sql create mode 100644 server/sqlc/queries/errors.sql create mode 100644 server/sqlc/queries/miner_credentials.sql create mode 100644 server/sqlc/queries/miner_service.sql create mode 100644 server/sqlc/queries/organization.sql create mode 100644 server/sqlc/queries/pool.sql create mode 100644 server/sqlc/queries/queue.sql create mode 100644 server/sqlc/queries/role.sql create mode 100644 server/sqlc/queries/schedule.sql create mode 100644 server/sqlc/queries/session.sql create mode 100644 server/sqlc/queries/telemetry.sql create mode 100644 server/sqlc/queries/user.sql create mode 100644 server/sqlc/queries/user_organization.sql create mode 100644 server/timescaledb/.dockerignore create mode 100644 server/timescaledb/Dockerfile create mode 100644 server/timescaledb/README.md create mode 100644 server/timescaledb/docker-entrypoint.sh create mode 100644 tests/plugin-contract/go.mod create mode 100644 tests/plugin-contract/go.sum create mode 100644 tests/plugin-contract/harness/antminer.go create mode 100644 tests/plugin-contract/harness/asicrs.go create mode 100644 tests/plugin-contract/harness/harness.go create mode 100644 tests/plugin-contract/miners/antminer_stock_test.go create mode 100644 tests/plugin-contract/miners/antminer_vnish_test.go create mode 100644 tests/plugin-contract/miners/contract.go create mode 100644 tests/plugin-contract/miners/manifest.go create mode 100644 tests/plugin-contract/miners/whatsminer_stock_test.go create mode 100644 tests/plugin-contract/mockapi/antminer/server.go create mode 100644 tests/plugin-contract/mockapi/mockapi.go create mode 100644 tests/plugin-contract/mockapi/vnish/server.go create mode 100644 tests/plugin-contract/mockapi/whatsminer/server.go create mode 100644 tests/plugin-contract/testdata/antminer-stock/manifest.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/rpc/devs.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/rpc/pools.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/rpc/summary.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/rpc/version.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/web/blink.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/web/get_miner_conf.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/web/get_system_info.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/web/reboot.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/web/set_miner_conf.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/web/stats.json create mode 100644 tests/plugin-contract/testdata/antminer-stock/web/summary.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/manifest.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/privileged/find-miner.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/privileged/mining/resume.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/privileged/mining/start.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/privileged/mining/stop.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/privileged/settings.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/privileged/system/reboot.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/rpc/devdetails.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/rpc/pools.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/rpc/stats.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/rpc/summary.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/rpc/version.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/web/autotune/presets.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/web/info.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/web/perf-summary.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/web/root.html create mode 100644 tests/plugin-contract/testdata/antminer-vnish/web/settings.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/web/summary.json create mode 100644 tests/plugin-contract/testdata/antminer-vnish/web/unlock.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/manifest.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/power_off.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/power_on.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/reboot.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/set_high_power.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/set_led.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/set_low_power.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/set_normal_power.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/privileged/update_pools.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/devdetails.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/devs.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/edevs.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/get_error_code.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/get_miner_info.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/get_psu.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/get_token.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/get_version.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/pools.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/status.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/summary.json create mode 100644 tests/plugin-contract/testdata/whatsminer-stock/rpc/version.json diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..b96a467ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,79 @@ +name: Bug Report +description: Report a bug or unexpected behavior in Proto Fleet +labels: ["bug"] +body: + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + id: preconditions + attributes: + label: Preconditions + description: Any relevant conditions or setup required to reproduce (e.g., number of miners, network conditions, specific configuration). + placeholder: "e.g., At least 10 miners added, unstable network connection" + validations: + required: false + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + + - type: input + id: version + attributes: + label: Proto Fleet version + description: The version of Proto Fleet you are running (e.g., v1.2.3 or commit SHA). + placeholder: v1.0.0 + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: What environment are you running Proto Fleet in? + placeholder: | + OS: Ubuntu 22.04 + Browser: Chrome 120 + Docker: 24.0.7 + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Paste any relevant log output. + render: shell + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: Screenshots & screen recordings + description: If applicable, add screenshots or screen recordings to help explain the problem. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0ba9db253..9d9004950 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,8 @@ +blank_issues_enabled: false contact_links: - - name: ❓ Questions and Help 🤔 - url: https://discord.gg/block-opensource (/add your discord channel if applicable) - about: This issue tracker is not for support questions. Please refer to the community for more help. + - name: Questions & Discussion + url: https://github.com/block/proto-fleet/discussions + about: Ask questions and discuss ideas in GitHub Discussions + - name: Security Vulnerabilities + url: https://github.com/block/proto-fleet/blob/main/SECURITY.md + about: Report security vulnerabilities via our security policy diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..6bd627d7c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,59 @@ +name: Feature Request +description: Suggest a new feature or improvement for Proto Fleet +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem or motivation + description: Describe the problem you'd like solved or the motivation behind this request. + placeholder: "As a fleet operator, I need to ___ because ___" + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the solution you'd like to see. + validations: + required: true + + - type: checkboxes + id: duplicate-check + attributes: + label: Duplicate check + options: + - label: I have searched existing issues and this is not a duplicate + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe any alternative solutions or features you've considered. + validations: + required: false + + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Proto Fleet does this relate to? + options: + - Client (Web UI) + - Server (Go backend) + - ASIC Plugin (asicrs/Rust) + - Protobufs/API + - Docker/Deployment + - Other + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context, mockups, or screenshots about the feature request. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/miner-compatibility.yml b/.github/ISSUE_TEMPLATE/miner-compatibility.yml new file mode 100644 index 000000000..705a9e0a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/miner-compatibility.yml @@ -0,0 +1,55 @@ +name: Miner Compatibility Issue +description: Report a compatibility issue with a specific miner model or firmware +labels: ["miner-compatibility"] +body: + - type: input + id: miner-model + attributes: + label: Miner model + description: The make and model of the miner. + placeholder: "e.g., Antminer S21, Whatsminer M50S" + validations: + required: true + + - type: input + id: firmware-version + attributes: + label: Firmware version + description: The firmware running on the miner. + placeholder: "e.g., stock firmware 2024.01, Braiins OS+ 23.12" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Describe the issue + description: A clear description of the compatibility issue you encountered. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + + - type: input + id: version + attributes: + label: Proto Fleet version + description: The version of Proto Fleet you are running (e.g., v1.2.3 or commit SHA). + placeholder: v1.0.0 + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or telemetry output + description: Paste any relevant logs or telemetry output from the miner or Proto Fleet. + render: shell + validations: + required: false diff --git a/.github/actions/deploy-protofleet/action.yml b/.github/actions/deploy-protofleet/action.yml new file mode 100644 index 000000000..58a5146f2 --- /dev/null +++ b/.github/actions/deploy-protofleet/action.yml @@ -0,0 +1,129 @@ +name: Deploy ProtoFleet +description: Deploy ProtoFleet deployment bundle to a target environment + +inputs: + artifact_name: + description: 'Name of the deployment bundle artifact' + required: true + install_dir: + description: 'Installation directory (defaults to $HOME/proto-fleet)' + required: false + default: '' + +runs: + using: composite + steps: + - name: Clean up old deployment bundles + shell: bash + run: | + echo "Cleaning up old deployment bundles from /tmp..." + rm -f /tmp/proto-fleet*.tar.gz + echo "Cleanup complete" + + - name: Download deployment bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact_name }} + path: /tmp + + - name: Deploy ProtoFleet + shell: bash + run: | + set -e + + # Determine installation directory + INSTALL_DIR="${{ inputs.install_dir }}" + if [ -z "$INSTALL_DIR" ]; then + INSTALL_DIR="$HOME/proto-fleet" + fi + + echo "Installing ProtoFleet to: $INSTALL_DIR" + + # Create installation directory + mkdir -p "$INSTALL_DIR" + + # Check if there's an existing .env file to preserve + ENV_FILE="$INSTALL_DIR/deployment/server/influx_config/.env" + if [ -f "$ENV_FILE" ]; then + echo "Backing up existing .env file..." + cp "$ENV_FILE" /tmp/.env.backup + fi + + # Find the deployment bundle (prioritize versioned releases, then dev builds) + # List all potential bundles for debugging + echo "Looking for deployment bundle in /tmp..." + ls -lh /tmp/proto-fleet*.tar.gz 2>/dev/null || echo "No proto-fleet bundles found yet" + + # Prefer versioned bundles (proto-fleet-v1.2.3.tar.gz) over dev builds (proto-fleet-deployment.tar.gz) + BUNDLE_FILE=$(find /tmp -maxdepth 1 -name "proto-fleet-v*.tar.gz" | head -n 1) + + if [ -z "$BUNDLE_FILE" ]; then + # Fall back to dev/deployment naming + BUNDLE_FILE=$(find /tmp -maxdepth 1 -name "proto-fleet-deployment*.tar.gz" | head -n 1) + fi + + if [ -z "$BUNDLE_FILE" ]; then + # Last resort: any proto-fleet tarball + BUNDLE_FILE=$(find /tmp -maxdepth 1 -name "proto-fleet-*.tar.gz" | head -n 1) + fi + + if [ -z "$BUNDLE_FILE" ]; then + echo "❌ Error: Could not find deployment bundle in /tmp" + echo "Contents of /tmp:" + ls -la /tmp + exit 1 + fi + + # Verify the bundle file size is reasonable (should be at least 1MB) + BUNDLE_SIZE=$(stat -c%s "$BUNDLE_FILE" 2>/dev/null || stat -f%z "$BUNDLE_FILE" 2>/dev/null || echo "0") + if [ "$BUNDLE_SIZE" -lt 1048576 ]; then + echo "❌ Error: Bundle file is too small ($BUNDLE_SIZE bytes), download may have failed" + echo "Bundle file: $BUNDLE_FILE" + ls -lh "$BUNDLE_FILE" + exit 1 + fi + + # Extract deployment bundle + echo "✅ Found deployment bundle: $BUNDLE_FILE ($(numfmt --to=iec-i --suffix=B $BUNDLE_SIZE 2>/dev/null || echo ${BUNDLE_SIZE} bytes))" + echo "Extracting to: $INSTALL_DIR" + tar -xzf "$BUNDLE_FILE" -C "$INSTALL_DIR" + + # Restore .env file if it existed + if [ -f "/tmp/.env.backup" ]; then + echo "Restoring .env file..." + cp /tmp/.env.backup "$ENV_FILE" + rm /tmp/.env.backup + fi + + # Clean up tarball + rm "$BUNDLE_FILE" + + # Navigate to deployment directory + cd "$INSTALL_DIR/deployment" + + # Make run-fleet.sh executable + chmod +x run-fleet.sh + + # Run the deployment script + echo "Running deployment script..." + ./run-fleet.sh + + echo "Deployment complete!" + + - name: Show deployment status + shell: bash + run: | + INSTALL_DIR="${{ inputs.install_dir }}" + if [ -z "$INSTALL_DIR" ]; then + INSTALL_DIR="$HOME/proto-fleet" + fi + + cd "$INSTALL_DIR/deployment" + + echo "Deployment complete!" + echo "" + echo "Deployment version:" + cat version.txt + echo "" + echo "Container status:" + docker compose ps diff --git a/.github/actions/go-cache-setup/action.yml b/.github/actions/go-cache-setup/action.yml new file mode 100644 index 000000000..ba244b1c2 --- /dev/null +++ b/.github/actions/go-cache-setup/action.yml @@ -0,0 +1,16 @@ +name: Configure Go Cache +description: Setup Go build and module caching for workspace + +runs: + using: "composite" + steps: + - name: Configure Go cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-${{ runner.arch }}-go-workspace-${{ hashFiles('**/go.sum', 'go.work') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-go-workspace- + ${{ runner.os }}-${{ runner.arch }}-go- diff --git a/.github/actions/hermit-setup/action.yml b/.github/actions/hermit-setup/action.yml new file mode 100644 index 000000000..9b68e32a6 --- /dev/null +++ b/.github/actions/hermit-setup/action.yml @@ -0,0 +1,25 @@ +name: Hermit Setup +description: Setup Hermit with caching +inputs: + preinstall: + description: 'If "true", pre-install all hermit packages.' + required: false + default: 'true' + +runs: + using: "composite" + steps: + - name: Activate hermit + uses: cashapp/activate-hermit@12a728b03ad41eace0f9abaf98a035e7e8ea2318 # v1.1.4 + with: + cache: "false" # Disable hermit cache to avoid it evicting yocto cache for now + + - name: Install all hermit packages + if: ${{ inputs.preinstall == 'true'}} + shell: bash + run: | + if [[ ${RUNNER_OS} = 'Linux' && ${RUNNER_ARCH} == 'ARM64' ]]; then + echo "Skipping hermit install all for ${RUNNER_NAME} (${RUNNER_OS}:${RUNNER_ARCH})" + else + hermit install + fi diff --git a/.github/client-e2e-tests.yml b/.github/client-e2e-tests.yml new file mode 100644 index 000000000..7b4a04ce6 --- /dev/null +++ b/.github/client-e2e-tests.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..ca54a8efa --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,160 @@ +# GitHub Copilot Instructions + +## Project Overview + +This is a monorepo for a miner management system with the following structure: + +- **Client**: TypeScript, React, and Tailwind CSS applications +- **Server**: Go-based backend service +- **Miner Firmware**: Rust-based firmware for mining hardware + +## General Guidelines + +### What NOT to Review or Modify + +- Auto-generated code in directories like: + - `generated/`, `gen/`, `.generated/` + - `build/`, `dist/`, `target/`, `out/` + - `node_modules/`, `vendor/` + - `*.pb.go`, `*.pb.ts`, `*.generated.*` + - Build artifacts and compiled binaries + - Package lock files (package-lock.json, yarn.lock, go.sum, etc.) + +### Code Review Focus + +- Focus review on meaningful code changes +- Check for potential security vulnerabilities +- Ensure proper error handling is implemented +- Verify that new functions include appropriate unit tests +- Review for performance optimizations +- Note any potential accessibility issues + +## Server (Go Backend) + +### Tech Stack + +- Go +- Connect RPC (gRPC-compatible) for fleet API endpoints +- MySQL database with golang-migrate for migrations +- sqlc for type-safe SQL query generation +- Protobuf for fleet API definitions +- Docker for containerization + +### Key Directories + +- `cmd/`: Entry points for the service +- `internal/`: Core business logic and domain models +- `generated/`: Auto-generated code (sqlc, fleet gRPC) +- `migrations/`: Database migration files +- `sqlc/`: SQL query definitions for code generation + +### Server Development Guidelines + +- Ensure proper error handling with context-aware error messages +- Check that all database queries use prepared statements to prevent SQL injection +- Verify that context is properly propagated through all function calls +- Ensure proper use of goroutines and channels with no race conditions +- Check for proper resource cleanup (defer statements for closing connections, etc.) +- Verify that API endpoints have proper authentication and authorization +- Ensure sensitive data is not logged or exposed in error messages +- Check that database transactions are properly committed or rolled back +- Ensure proper validation of all user inputs at API boundaries +- Verify that migrations are backward compatible and include rollback logic +- Ensure proper use of interfaces for testability and dependency injection +- Check that configuration values are validated and have sensible defaults +- Ensure proper handling of concurrent requests and data races +- Check that API responses follow consistent error formats +- Verify that all public functions have appropriate documentation +- Ensure that unit tests cover critical business logic +- Check for proper use of context cancellation and deadlines + +## Client (React Applications) + +### Applications + +- **ProtoOS**: Mining dashboard UI served by the miner-api-server +- **ProtoFleet**: Fleet management UI for managing multiple miners + +### Tech Stack + +- TypeScript with React +- Vite for build tooling +- Tailwind CSS for styling +- Recharts for data visualization +- Storybook for component development +- ESLint for code quality +- PostCSS for CSS processing + +### Key Directories + +- `src/protoOS/`: Mining dashboard application +- `src/protoFleet/`: Fleet management application +- `src/shared/`: Shared components, utilities, and types +- `dist/`: Production builds (auto-generated) + +### API Integration + +- ProtoOS uses auto-generated TypeScript types from `src/protoOS/api/types.ts` (this file is auto-generated and should **not** be reviewed or modified; it is referenced here for context only) +- ProtoFleet connects to the Go backend service via gRPC-web +- Proto plugin communicates with miners via REST API (MDK-API OpenAPI spec) +- Development proxies configured in vite.config.ts + +### Client Development Guidelines + +- Ensure all React components follow functional component patterns with hooks +- Check that TypeScript types are properly defined and avoid using 'any' +- Verify Tailwind classes are used consistently and follow the design system +- Ensure proper error boundaries are implemented for critical UI sections +- Verify that API calls include proper error handling and loading states +- Ensure components are properly memoized where performance is critical +- Check that shared components are truly reusable between both apps +- Verify that environment variables are properly typed and validated +- Ensure proper cleanup in useEffect hooks to prevent memory leaks +- Check for proper form validation and user input sanitization +- Verify that responsive design is implemented for all screen sizes +- Ensure proper code splitting and lazy loading for optimal performance +- Check that Storybook stories exist for new components +- Verify that console.log statements are removed from production code. console.error statements are fine. + +## Pull Request Guidelines + +When reviewing or creating pull requests: + +1. Review the high level architecture. +2. Focus on the specific changes being made, not unrelated code +3. Ensure all tests pass before submitting +4. Include relevant documentation updates +5. Follow the project's commit message conventions + +## Development Best Practices + +### General + +- Write clean, self-documenting code +- Add comments only for complex business logic or non-obvious implementations +- Follow the existing code style and conventions +- Ensure proper error handling throughout +- Write comprehensive tests for new functionality +- Keep functions small and focused on a single responsibility + +### Security + +- Never hardcode credentials or sensitive information +- Validate all user inputs +- Use prepared statements for database queries +- Implement proper authentication and authorization +- Avoid logging sensitive data +- Use HTTPS/TLS for all network communications + +### Performance + +- Profile before optimizing +- Use appropriate data structures +- Implement proper caching strategies +- Avoid premature optimization +- Consider concurrent request handling +- Monitor resource usage + +## Project-Specific Notes + +Each project in this monorepo may have their own README.md files which contain additional relevant information. Project-specific configurations should be treated as additions to these instructions. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0117d9ae4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + + - package-ecosystem: npm + directory: client + schedule: + interval: monthly + groups: + node-dependencies: + applies-to: version-updates + patterns: + - "*" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + - dependency-name: "swagger-typescript-api" + + - package-ecosystem: docker + directory: / + schedule: + interval: monthly diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..762b30c4b --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,19 @@ +automation: + - changed-files: + - any-glob-to-any-file: .github/** + +client: + - changed-files: + - any-glob-to-any-file: client/** + +server: + - changed-files: + - any-glob-to-any-file: server/** + +shared: + - changed-files: + - any-glob-to-any-file: proto/** + +tooling: + - changed-files: + - any-glob-to-any-file: bin/** diff --git a/.github/release-configs/web.json b/.github/release-configs/web.json new file mode 100644 index 000000000..e104fdaad --- /dev/null +++ b/.github/release-configs/web.json @@ -0,0 +1,37 @@ +{ + "template": "#{{CHANGELOG}}\n**Full Changelog**: ${{RELEASE_DIFF}}", + "pr_template": "- #${{NUMBER}} ${{TITLE}} by @${{AUTHOR}}", + "empty_template": "This release has no changes. See the previous release for notes.", + "categories": [ + { + "title": "## 🚀 Changelog", + "labels": [ + "web" + ], + "exclude_labels": [ + "dependencies", + "versions" + ], + "empty_content": "- no changes" + }, + { + "title": "## 📦 Dependencies", + "labels": [ + "web", + "dependencies" + ], + "empty_content": "- no new dependencies", + "exhaustive": true + } + ], + "tag_resolver": { + "method": "semver", + "filter": { + "pattern": "web-(.+)", + "method": "match", + "flags": "gu" + } + }, + "max_pull_requests": 1000 + } + \ No newline at end of file diff --git a/.github/workflows/RASPBERRY_PI_DEPLOYMENT.md b/.github/workflows/RASPBERRY_PI_DEPLOYMENT.md new file mode 100644 index 000000000..fc3db5791 --- /dev/null +++ b/.github/workflows/RASPBERRY_PI_DEPLOYMENT.md @@ -0,0 +1,255 @@ +# Raspberry Pi Deployment Workflows + +This document describes the GitHub workflows for deploying ProtoFleet to Raspberry Pi devices. + +## Overview + +ProtoFleet can be deployed to Raspberry Pi devices in two ways: + +1. **Manual Deployment** (`protofleet-deploy-to-pi.yml`): Deploy from any branch to a specific Pi via workflow dispatch +2. **Automatic Release Deployment** (`release.yml`): Automatically deploy to all Pis when a new release is published + +Both workflows use self-hosted GitHub Actions runners on the Raspberry Pis and share a common deployment action for consistency. + +## Workflow: `protofleet-deploy-to-pi.yml` (Manual) + +This workflow allows manual deployment of ProtoFleet from any branch to a specified Raspberry Pi. + +### Triggering the Workflow + +The workflow uses `workflow_dispatch` for manual triggering through the GitHub Actions UI. + +**Required Inputs:** + +- **branch**: The git branch to build from (default: `main`) +- **environment**: The deployment environment (Raspberry Pi location) to deploy to. Choose from: + - `pi-stl` - St. Louis location + - `pi-mar` - Marina location + - `pi-fxsj` - FXSJ location + +### Workflow Steps + +The workflow consists of four jobs that run sequentially: + +#### 1. `build-proto-fleet-server` + +- Checks out the specified branch +- Sets up Go build environment with Hermit +- Builds server binaries for both amd64 and arm64 architectures +- Builds plugin binaries (proto-plugin and antminer-plugin) for both architectures +- Creates a version.txt file with build metadata +- Packages everything into a tarball +- Uploads as a GitHub Actions artifact + +#### 2. `build-proto-fleet-client` + +- Checks out the specified branch +- Sets up Node.js build environment +- Installs npm dependencies +- Builds the ProtoFleet client application +- Creates a version.txt file with build metadata +- Packages the client into a tarball +- Uploads as a GitHub Actions artifact + +#### 3. `build-deployment-bundle` + +- Downloads both server and client artifacts from previous jobs +- Creates the deployment directory structure +- Extracts server and client artifacts into the deployment structure +- Copies all deployment configuration files: + - Docker Compose configuration + - Dockerfiles for client and server + - run-fleet.sh deployment script + - TimescaleDB configuration +- Creates a comprehensive version.txt file +- Packages everything into a single deployment tarball +- Uploads the deployment bundle as an artifact + +#### 4. `deploy-to-pi` + +- Uses the shared composite action `.github/actions/deploy-protofleet` for consistent deployment logic +- Downloads the deployment bundle artifact +- Deploys ProtoFleet to the target Pi: + - Determines installation directory (from input or default) + - Backs up existing `.env` file if present (preserves database credentials) + - Extracts the deployment bundle + - Restores the `.env` file + - Runs `run-fleet.sh` which: + - Checks for and installs Docker if needed + - Validates/generates environment variables (DB passwords, encryption keys) + - Pulls Docker images + - Builds Docker containers for the correct architecture (arm64/amd64) + - Starts all services via Docker Compose +- Displays the deployed version information + +### Reusable Deployment Action + +A shared composite action (`.github/actions/deploy-protofleet`) encapsulates the deployment logic used by both the manual deployment workflow and the automatic release deployment. This ensures: + +- **Consistency**: Same deployment process across manual and automated deployments +- **Maintainability**: Single source of truth for deployment logic +- **Reusability**: Easy to add new deployment targets without code duplication + +**Inputs:** + +- `artifact_name`: Name of the deployment bundle artifact to download +- `install_dir`: Optional installation directory (defaults to `$HOME/proto-fleet`) + +**Steps:** + +1. Downloads the specified deployment bundle artifact +2. Extracts and deploys to the target directory +3. Preserves existing `.env` files across updates +4. Runs the deployment script (`run-fleet.sh`) +5. Verifies deployment by checking container status + +Both `protofleet-deploy-to-pi.yml` (manual) and `release.yml` (automatic) use this shared action. + +### Deployment Process Details + +The deployment follows the same pattern as the install.sh script: + +1. **Environment Preservation**: Existing `.env` files are backed up and restored to prevent loss of database credentials during upgrades + +2. **Docker Setup**: The `run-fleet.sh` script ensures Docker and Docker Compose are installed and running + +3. **Architecture Detection**: Automatically detects ARM64 vs AMD64 architecture and uses the appropriate binaries + +4. **Service Management**: + - Stops existing containers + - Builds new images with the latest code + - Starts all services (TimescaleDB, Fleet API, Frontend) + +5. **Plugin Validation**: Ensures all required plugin binaries are present and executable + +### Usage Example + +To deploy the latest code from the `main` branch to a Raspberry Pi: + +1. Navigate to **Actions** → **ProtoFleet Deploy to Raspberry Pi** +2. Click **Run workflow** +3. Fill in the inputs: + - **branch**: `main` + - **environment**: Select the target location (e.g., `pi-stl`, `pi-mar`, or `pi-fxsj`) +4. Click **Run workflow** + +### Adding New Deployment Locations + +To add a new Raspberry Pi deployment location: + +1. **Set up the Pi as a self-hosted runner**: + - Navigate to Settings → Actions → Runners → [New self-hosted runner](https://github.com/proto-at-block/proto-fleet/settings/actions/runners/new?arch=arm64&os=linux) + - Follow the instructions to install ARM64 Architecture and configure the runner on the Pi + - In addition to the default labels, add the following labels to the runner (comma separated): + - `proto-fleet-rpi` + - `pi-new-location` (the environment name) + - Configure the self-hosted runner as a [service](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application) on the pi + +2. **Update both workflow files** to include the new location: + + In `protofleet-deploy-to-pi.yml`: + + ```yaml + environment: + description: 'Deployment environment (Raspberry Pi location)' + required: true + type: choice + options: + - pi-stl + - pi-mar + - pi-fxsj + - pi-dalton + - pi-new-location # Add your new location here + ``` + + **For staged release deployments**, decide if the new location should be: + + - **Testing environment** (auto-deploys first): + Update `release.yml` (deploy-to-testing-env job): + + ```yaml + runs-on: [self-hosted, proto-fleet-rpi, 'pi-new-location'] + ``` + + - **Production environment** (requires approval): + Update `release.yml` (deploy-to-all-envs job): + + ```yaml + strategy: + matrix: + environment: [pi-stl, pi-fxsj, pi-dalton, pi-new-location] + ``` + +The new location will now be: + +- Available for manual deployments via the workflow dispatch UI +- Included in either the testing or production stage of release deployments + +### Deploying to Multiple Locations (Release Workflow) + +**Staged deployment to Raspberry Pis is implemented in the `release.yml` workflow!** + +When a new release is published (non-prerelease), the workflow uses a two-stage deployment approach: + +#### Stage 1: Testing Environment (`deploy-to-testing-env`) + +- Automatically deploys to **pi-mar** (testing environment) +- No manual approval required +- Allows validation of the release before production deployment +- Uses the `testing-env` GitHub environment (no protection rules) +- Runs immediately after build artifacts are created +- **Timeout**: 30 minutes - job fails gracefully if runner is offline +- **Health check**: Verifies runner is online and has sufficient disk space + +#### Stage 2: Production Environments (`deploy-to-all-envs`) + +- Deploys to **pi-stl**, **pi-fxsj**, and **pi-dalton** in parallel +- **Requires manual approval** before deployment begins +- Only starts after testing environment deployment succeeds +- Uses the `all-envs` GitHub environment (configured with required reviewers) +- Independent failures via `fail-fast: false` - one Pi failure doesn't stop others +- **Timeout**: 30 minutes per Pi - job fails gracefully if runner is offline +- **Health check**: Each Pi verifies runner is online before deployment + +**Deployment Flow:** + +``` +1. Build artifacts (client, server, full deployment bundle) + ↓ +2. Deploy to pi-mar (testing-env) - AUTOMATIC + ↓ + [Validate deployment on pi-mar] + ↓ +3. Workflow pauses and waits for manual approval + ↓ + [Reviewer approves in GitHub UI] + ↓ +4. Deploy to pi-stl, pi-fxsj, pi-dalton (all-envs) - IN PARALLEL +``` + +**Key Features:** + +- Safe deployment with testing validation before production +- Manual approval gate prevents accidental production deployments +- Clear audit trail of who approved production deployments +- Parallel deployment to production Pis for faster rollout +- Reuses artifacts from the build phase (efficient, no rebuild) + +### Approving Production Deployments + +When the workflow reaches the `deploy-to-all-envs` job: + +1. **GitHub sends notifications** to required reviewers +2. **Navigate to the workflow run** in the Actions tab +3. You'll see a yellow banner: **"Review required for all-envs"** +4. Click **Review deployments** +5. Select the **all-envs** environment checkbox +6. (Optional) Add a comment about validation performed on pi-mar +7. Click **Approve and deploy** + +The deployment to the 3 production Pis will begin immediately after approval. + +### Additional Resources + +- [GitHub Actions Documentation](https://docs.github.com/en/actions) +- [Raspberry Pi SSH Setup](https://www.raspberrypi.com/documentation/computers/remote-access.html#ssh) diff --git a/.github/workflows/asicrs-plugin-checks.yml b/.github/workflows/asicrs-plugin-checks.yml new file mode 100644 index 000000000..a9ee225ef --- /dev/null +++ b/.github/workflows/asicrs-plugin-checks.yml @@ -0,0 +1,98 @@ +name: asicrs Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/asicrs-plugin-checks.yml" + - "plugin/asicrs/**" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + push: + branches: + - main + paths: + - ".github/workflows/asicrs-plugin-checks.yml" + - "plugin/asicrs/**" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + rust-lint: + name: Lint & Format + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/asicrs + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt, clippy + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + plugin/asicrs/target + key: ${{ runner.os }}-asicrs-${{ hashFiles('plugin/asicrs/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-asicrs- + + - name: Check formatting + run: cargo fmt -- --check + + - name: Clippy + run: cargo clippy -- -D warnings + + rust-build-test: + name: Build & Test + runs-on: ubuntu-latest + needs: [rust-lint] + defaults: + run: + working-directory: plugin/asicrs + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + plugin/asicrs/target + key: ${{ runner.os }}-asicrs-${{ hashFiles('plugin/asicrs/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-asicrs- + + - name: Build + run: cargo build + + - name: Test + run: cargo test diff --git a/.github/workflows/codex-security-review.yml b/.github/workflows/codex-security-review.yml new file mode 100644 index 000000000..b7f734844 --- /dev/null +++ b/.github/workflows/codex-security-review.yml @@ -0,0 +1,286 @@ +name: Codex Security Review + +on: + pull_request: + types: [opened, reopened, ready_for_review, synchronize] + +jobs: + security-review: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: codex-security-review-${{ github.ref }} + cancel-in-progress: true + permissions: + contents: read + issues: write + pull-requests: write + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CODEX_MODEL: gpt-5.4 + CODEX_REASONING_EFFORT: xhigh + REVIEW_SCOPE_LABEL: ${{ github.event_name == 'pull_request' && 'PR diff only' || 'Push diff only' }} + REVIEW_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || (github.event.before && !startsWith(github.event.before, '0000000000000000000000000000000000000000') && github.event.before || github.sha) }} + REVIEW_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + REVIEW_COMMIT_RANGE: ${{ github.event_name == 'pull_request' && format('{0}...{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) || (github.event.before && !startsWith(github.event.before, '0000000000000000000000000000000000000000') && format('{0}..{1}', github.event.before, github.sha) || format('{0}..{0}', github.sha)) }} + REVIEW_BLOB_BASE_URL: ${{ format('https://github.com/{0}/blob/{1}', github.repository, github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }} + REVIEW_DIFF_FILE: .git/codex-review.diff + steps: + - name: Checkout push commit + if: github.event_name == 'push' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Checkout PR head commit + if: startsWith(github.event_name, 'pull_request') + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Fetch exact PR base and head commits + if: startsWith(github.event_name, 'pull_request') + env: + GIT_TERMINAL_PROMPT: 0 + REVIEW_BASE_SHA: ${{ env.REVIEW_BASE_SHA }} + REVIEW_HEAD_SHA: ${{ env.REVIEW_HEAD_SHA }} + run: | + git -c protocol.version=2 \ + fetch --no-tags origin \ + "$REVIEW_BASE_SHA" \ + "$REVIEW_HEAD_SHA" + + - name: Write review diff + env: + REVIEW_COMMIT_RANGE: ${{ env.REVIEW_COMMIT_RANGE }} + REVIEW_DIFF_FILE: ${{ env.REVIEW_DIFF_FILE }} + run: | + git diff --find-renames --submodule=diff --unified=80 "$REVIEW_COMMIT_RANGE" > "$REVIEW_DIFF_FILE" + + - name: Run Codex Security Review + id: run_codex + if: ${{ env.OPENAI_API_KEY != '' }} + uses: openai/codex-action@c25d10f3f498316d4b2496cc4c6dd58057a7b031 # v1.6 + with: + openai-api-key: ${{ env.OPENAI_API_KEY }} + model: ${{ env.CODEX_MODEL }} + codex-args: '["-c","model_reasoning_effort=${{ env.CODEX_REASONING_EFFORT }}"]' + + safety-strategy: drop-sudo + sandbox: read-only + + prompt: | + # Proto Fleet Security, Correctness & Reliability Review + + You are reviewing a pull request for **Proto Fleet**, an open-source fleet + management platform for Bitcoin miners. The architecture includes: + - **Go backend** (`server/`): Connect-RPC/gRPC handlers, JWT authentication, + PostgreSQL/TimescaleDB with sqlc-generated queries, database migrations, + device pairing, telemetry collection, and command execution queues + - **React/TypeScript frontend** (`client/`): Two apps — ProtoOS (single-miner + REST dashboard) and ProtoFleet (fleet-wide gRPC streaming UI) — using Vite, + Zustand, and Connect-RPC + - **Go plugin system** (`plugin/`): HashiCorp go-plugin based device drivers + for Antminer, Proto miner, and virtual devices — each runs as a separate + process communicating over gRPC + - **Rust ASIC plugin** (`plugin/asicrs/`): Rust-based multi-manufacturer ASIC miner + control via gRPC + - **Example Python plugin** (`plugin/example-python/`): minimal example plugin for reference + - **Network discovery**: Nmap scanning and mDNS/Zeroconf for automatic device + discovery on the local network + - **Infrastructure**: Docker multi-stage builds, Docker Compose orchestration, + Nginx reverse proxy, multi-arch (amd64/arm64) deployment + + Perform a review focused strictly on the latest changes. + Start by reading `${{ env.REVIEW_DIFF_FILE }}` and treat it as the authoritative review scope. + - The checked out repository contents are pinned to commit `${{ env.REVIEW_HEAD_SHA }}`. + - For pull request runs, `${{ env.REVIEW_DIFF_FILE }}` was generated from the exact PR diff `${{ env.REVIEW_COMMIT_RANGE }}`. + - For push runs, `${{ env.REVIEW_DIFF_FILE }}` was generated from the exact push diff `${{ env.REVIEW_COMMIT_RANGE }}`. + Focus on: + - **Authentication & authorization**: JWT token handling, session management, missing auth checks on endpoints, privilege escalation + - **SQL injection & database security**: Raw SQL in migrations or queries bypassing sqlc, unsafe interpolation, credential exposure, migration ordering issues + - **gRPC/Connect-RPC security**: Missing request validation, sensitive data in error responses, unbounded streaming, missing protobuf field validation + - **Command injection**: Especially in Nmap invocations, miner API calls, plugin command execution, and any shell-out patterns (exec.Command) + - **Network discovery trust boundaries**: Spoofed mDNS responses, SSRF via crafted device addresses, trusting unvalidated data from discovered devices + - **Plugin system safety**: HashiCorp go-plugin trust boundaries, malicious plugin responses, unvalidated data crossing the plugin gRPC boundary + - **Concurrency hazards**: Goroutine leaks, race conditions on shared state, channel misuse, mutex deadlocks, unsafe map access + - **Reliability risks**: Unrecovered panics in handlers, unbounded memory/CPU from device telemetry floods, resource leaks (DB connections, HTTP clients, goroutines) + - **Frontend security**: XSS via device-supplied data rendered in React, credential/token exposure in client state or localStorage, insecure API error handling + - **Infrastructure risks**: Docker container privilege escalation, exposed ports, secrets in Docker Compose or build args, insecure Nginx config + - **Rust ASIC plugin security**: Unsafe blocks, unvalidated miner responses, dependency confusion risks + - **Cryptostealing & pool hijacking**: Code that swaps, overrides, or silently modifies mining pool URLs, stratum addresses, wallet/payout addresses, or worker credentials — whether in backend handlers, plugin responses, miner command payloads, database migrations, frontend state, or configuration defaults. Flag any hardcoded wallet addresses, obfuscated address strings, conditional logic that redirects hashrate or payouts, or pool configuration that differs from user-supplied values. This is critical — a compromised pool address means stolen hashrate and revenue. + - **Protocol Buffer / code generation**: Breaking wire-format changes, field type mismatches between generated Go/TypeScript/Python code + + ## Output Format + + Provide a structured review with: + + ## Review Summary + + **Overall Risk**: [CRITICAL|HIGH|MEDIUM|LOW|NONE] + + ### Findings + + #### [SEVERITY] Issue Title + - **Category**: Auth | SQLi/Database | gRPC | Command Injection | Network Discovery | Plugin | Concurrency | Reliability | Frontend | Infrastructure | Python | Protobuf | Cryptostealing/Pool Hijack | Other + - **Location**: [`path/to/file.go:123`](${{ env.REVIEW_BLOB_BASE_URL }}/path/to/file.go#L123) + - **Description**: Clear explanation of the security issue + - **Impact**: What could go wrong (security, correctness, reliability implications) + - **Recommendation**: Specific fix or mitigation + (Always render the **Location** line as a Markdown link that points to `${{ env.REVIEW_BLOB_BASE_URL }}/#L` so readers can jump to the exact commit you reviewed, and URL-encode any special characters in ``.) + + [Repeat for each finding] + + ### Notes + + [Any other relevant security, correctness, or reliability considerations] + Do not wrap the review in Markdown code fences. + + ## Important Constraints + + - Use `${{ env.REVIEW_DIFF_FILE }}` as the source of truth for changed hunks and locations + - For PR runs: review ONLY the exact PR diff `${{ env.REVIEW_COMMIT_RANGE }}`, not the merge commit, not the base branch tip, and not the entire codebase + - For push runs: review ONLY the exact push diff `${{ env.REVIEW_COMMIT_RANGE }}` + - Be specific: cite file paths and line numbers from the diff + - Prioritize high-impact issues; avoid stylistic or low-risk nits + + - name: Post Codex security review + if: steps.run_codex.outputs.final-message != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REVIEW_OUTPUT: ${{ steps.run_codex.outputs.final-message }} + with: + github-token: ${{ github.token }} + script: | + const review = process.env.REVIEW_OUTPUT; + const scopeLabel = process.env.REVIEW_SCOPE_LABEL || (context.eventName === 'pull_request' ? 'PR diff only' : 'Push diff only'); + const commitRange = process.env.REVIEW_COMMIT_RANGE || `${context.sha}..${context.sha}`; + const modelName = process.env.CODEX_MODEL || 'unknown'; + const scopeSummaryLine = context.eventName === 'pull_request' + ? `Reviewed pull request diff only (\`${commitRange}\`, exact PR three-dot diff)` + : `Reviewed push diff only (\`${commitRange}\`)`; + + const marker = ''; + const buildComment = (login) => `${marker} + ## 🔐 Codex Security Review + + > **Note**: This is an automated security-focused code review generated by Codex. + > It should be used as a supplementary check alongside human review. + > False positives are possible - use your judgment. + > + > **Scope summary** + > - ${scopeSummaryLine} + > - Model: ${modelName} + > + > 💡 *Click "edited" above to see previous reviews for this PR.* + + --- + + ${review} + + --- + + Generated by [Codex Security Review](https://github.com/openai/codex-action) | + Triggered by: @${login} | + [Review workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`; + + async function postOrUpdateComment(prNumber, login) { + const owner = context.repo.owner; + const repo = context.repo.repo; + const body = buildComment(login); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + const existingComment = comments.find(c => + c.user?.login === 'github-actions[bot]' && + c.user?.type === 'Bot' && + c.body?.includes(marker) + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: body, + }); + core.info(`Updated existing Codex review comment #${existingComment.id}`); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: body, + }); + core.info(`Created new Codex review comment on PR #${prNumber}`); + } + } + + try { + if (context.eventName === 'pull_request') { + await postOrUpdateComment( + context.payload.pull_request.number, + context.payload.pull_request.user.login + ); + return; + } + + if (context.eventName === 'push') { + const owner = context.repo.owner; + const repo = context.repo.repo; + const refName = process.env.GITHUB_REF_NAME; + const { data: prs } = await github.rest.pulls.list({ + owner, + repo, + head: `${owner}:${refName}`, + state: 'open', + }); + + if (!prs.length) { + core.info(`No open pull requests found for ${refName}; skipping PR comment.`); + return; + } + + const pr = prs[0]; + await postOrUpdateComment(pr.number, pr.user.login); + return; + } + + core.info(`Event ${context.eventName} is not associated with a pull request; skipping PR comment.`); + } catch (error) { + core.warning(`Unable to post Codex review comment: ${error.message}`); + } + + - name: Write review to job summary (push only) + if: github.event_name == 'push' && steps.run_codex.outputs.final-message != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REVIEW_OUTPUT: ${{ steps.run_codex.outputs.final-message }} + with: + script: | + const review = process.env.REVIEW_OUTPUT || ''; + const commitRange = process.env.REVIEW_COMMIT_RANGE || `${process.env.GITHUB_SHA}..${process.env.GITHUB_SHA}`; + const scopeLabel = process.env.REVIEW_SCOPE_LABEL || 'Push diff only'; + const modelName = process.env.CODEX_MODEL || 'unknown'; + await core.summary + .addHeading('🔐 Codex Security Review (push)', 2) + .addBreak() + .addList([ + `Ref: ${process.env.GITHUB_REF}`, + `Commit: ${process.env.GITHUB_SHA}`, + `Scope: ${scopeLabel} (${commitRange})`, + `Model: ${modelName}`, + ]) + .addBreak() + .addSeparator() + .addBreak() + .addRaw(review) + .write(); diff --git a/.github/workflows/generated-code-check.yml b/.github/workflows/generated-code-check.yml new file mode 100644 index 000000000..f25bfb3a9 --- /dev/null +++ b/.github/workflows/generated-code-check.yml @@ -0,0 +1,45 @@ +name: Generated code check + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + generated-code-check: + runs-on: ubuntu-latest + steps: + - name: Create GitHub App token + id: create_token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: "${{ steps.create_token.outputs.token }}" + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Install dependencies + run: just setup + + - name: Generate code + run: just gen + + - name: Check if committed generated code is up-to-date + run: > + git diff --quiet || { + echo -e "\033[0;31mNewly generated code does not match the committed code. Run 'just gen', then commit and push the changes.\033[0m" 1>&2; + echo -e "\n\n"; + git diff --exit-code + } diff --git a/.github/workflows/powershell-lint.yml b/.github/workflows/powershell-lint.yml new file mode 100644 index 000000000..f1682a6dc --- /dev/null +++ b/.github/workflows/powershell-lint.yml @@ -0,0 +1,85 @@ +name: PowerShell Lint (Windows Installer) + +on: + pull_request: + paths: + - "**/*.ps1" + push: + paths: + - "**/*.ps1" + +permissions: + contents: read + +jobs: + powershell-lint: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install PSScriptAnalyzer + shell: powershell + run: | + Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted + Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser + + - name: Parse PowerShell scripts + shell: powershell + run: | + $hadParseErrors = $false + Get-ChildItem -Path "." -Recurse -Filter "*.ps1" | ForEach-Object { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors -and $errors.Count -gt 0) { + $hadParseErrors = $true + Write-Host "Parse errors in $($_.FullName):" + $errors | ForEach-Object { Write-Host (" - " + $_.ToString()) } + } + } + if ($hadParseErrors) { + throw "PowerShell parser errors detected." + } + + - name: Guard unsafe WSL interpolation patterns + shell: powershell + run: | + $pattern = 'Invoke-WslUserCapture\s*".*\$\(' + $matches = Select-String -Path "deployment-files/windows/fleet-uninstaller.ps1" -Pattern $pattern + if ($matches) { + Write-Host "Unsafe interpolation detected:" + $matches | ForEach-Object { Write-Host (" " + $_.Filename + ":" + $_.LineNumber + " " + $_.Line.Trim()) } + throw "Unsafe PowerShell interpolation in WSL command literal." + } + + - name: Guard automatic variable assignments + shell: powershell + run: | + $pattern = '^\s*\$(home|error|input|matches|pid|pwd|args)\s*=' + $matches = Get-ChildItem -Path "deployment-files/windows" -Recurse -Filter "*.ps1" | + Select-String -Pattern $pattern -AllMatches -CaseSensitive:$false + if ($matches) { + Write-Host "Automatic variable assignment detected:" + $matches | ForEach-Object { Write-Host (" " + $_.Path + ":" + $_.LineNumber + " " + $_.Line.Trim()) } + throw "Assignment to PowerShell automatic variable is not allowed." + } + + - name: Lint PowerShell scripts + shell: powershell + run: | + Get-ChildItem -Path "." -Recurse -Filter "*.ps1" | Invoke-ScriptAnalyzer -Severity Error + + - name: Enforce automatic variable analyzer rule + shell: powershell + run: | + $findings = Invoke-ScriptAnalyzer ` + -Path "deployment-files/windows" ` + -Settings "deployment-files/windows/PSScriptAnalyzerSettings.psd1" ` + -Severity Warning,Error + if ($findings) { + $findings | ForEach-Object { + Write-Host ("[{0}] {1}:{2} {3}" -f $_.RuleName, $_.ScriptName, $_.Line, $_.Message) + } + throw "PSScriptAnalyzer automatic variable rule violations detected." + } diff --git a/.github/workflows/protofleet-antminer-plugin-checks.yml b/.github/workflows/protofleet-antminer-plugin-checks.yml new file mode 100644 index 000000000..ba3ff2a35 --- /dev/null +++ b/.github/workflows/protofleet-antminer-plugin-checks.yml @@ -0,0 +1,64 @@ +name: Antminer Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-antminer-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/antminer/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-antminer-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/antminer/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + antminer-plugin: + name: Antminer Plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/antminer + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: golangci-lint fmt --diff -c .golangci.yaml + - name: Build + run: just build + + - name: Check + run: just check diff --git a/.github/workflows/protofleet-client-checks.yml b/.github/workflows/protofleet-client-checks.yml new file mode 100644 index 000000000..5d024f962 --- /dev/null +++ b/.github/workflows/protofleet-client-checks.yml @@ -0,0 +1,113 @@ +name: ProtoFleet Client Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-client-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "client/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-client-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "client/**" + workflow_dispatch: + +jobs: + # Fast checks (lint, format, typecheck) run in a single job to reduce overhead + quality: + name: Lint, Format & Type Check + runs-on: ubuntu-latest + defaults: + run: + working-directory: client + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Cache npm dependencies + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Run TypeScript type check + run: npx tsc --noEmit + + test: + name: Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: client + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Cache npm dependencies + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test -- --run --reporter=verbose + + build: + name: Build + runs-on: ubuntu-latest + needs: [quality, test] + defaults: + run: + working-directory: client + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Cache npm dependencies + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.npm + key: ${{ runner.os }}-npm-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-npm- + + - name: Install dependencies + run: npm ci + + - name: Build ProtoOS + run: npm run build:protoOS + + - name: Build ProtoFleet + run: npm run build:protoFleet diff --git a/.github/workflows/protofleet-contract-checks.yml b/.github/workflows/protofleet-contract-checks.yml new file mode 100644 index 000000000..cb88a99c5 --- /dev/null +++ b/.github/workflows/protofleet-contract-checks.yml @@ -0,0 +1,62 @@ +name: Plugin Contract Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-contract-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "justfile" + - "tests/plugin-contract/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/asicrs/**" + - "plugin/proto/**" + - "plugin/antminer/**" + - "sdk/rust/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-contract-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "justfile" + - "tests/plugin-contract/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/asicrs/**" + - "plugin/proto/**" + - "plugin/antminer/**" + - "sdk/rust/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract-tests: + name: Contract Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Build plugins and run contract tests + run: just test-contract diff --git a/.github/workflows/protofleet-deploy-to-pi.yml b/.github/workflows/protofleet-deploy-to-pi.yml new file mode 100644 index 000000000..60bee6b1d --- /dev/null +++ b/.github/workflows/protofleet-deploy-to-pi.yml @@ -0,0 +1,217 @@ +name: ProtoFleet Deploy to Raspberry Pi + +on: + workflow_dispatch: + inputs: + branch: + description: 'Branch to build from' + required: true + default: 'main' + type: string + environment: + description: 'Raspberry Pi runner to deploy to (self-hosted runners with proto-fleet-rpi tag)' + required: true + type: choice + options: + - pi-stl + - pi-mar + - pi-fxsj + - pi-dalton + +permissions: + contents: read + +jobs: + build-proto-fleet-server: + runs-on: ubuntu-24.04-arm + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Build Golang server (arm64) + working-directory: ./server + run: | + go mod download + go build -v -o fleetd ./cmd/fleetd + echo "version: dev-${{ github.sha }}" > version.txt + echo "branch: ${{ inputs.branch }}" >> version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> version.txt + echo "commit: ${{ github.sha }}" >> version.txt + + - name: Build Go plugin binaries (arm64) + run: | + echo "Syncing Go workspace..." + go work sync + echo "Building Go plugins for arm64..." + go build -o server/proto-plugin ./plugin/proto + go build -o server/antminer-plugin ./plugin/antminer + chmod +x server/proto-plugin server/antminer-plugin + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build asicrs plugin binary (arm64) + run: | + docker buildx build \ + --file plugin/asicrs/Dockerfile.build \ + --output "type=local,dest=/tmp/asicrs" \ + . + cp "/tmp/asicrs/asicrs-plugin" "server/asicrs-plugin" + cp "/tmp/asicrs/asicrs-config.yaml" "server/asicrs-config.yaml" + chmod +x server/asicrs-plugin + echo "asicrs plugin built successfully for arm64" + + - name: Package server binaries (arm64) + working-directory: ./server + run: | + tar -czf proto-fleet-server-dev.tar.gz \ + fleetd \ + proto-plugin \ + antminer-plugin \ + asicrs-plugin \ + asicrs-config.yaml \ + version.txt + + - name: Upload server artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-server-artifact + path: ./server/proto-fleet-server-dev.tar.gz + retention-days: 1 + + build-proto-fleet-client: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + cache: "npm" + cache-dependency-path: "./client/package-lock.json" + + - name: Install dependencies + working-directory: ./client + run: npm ci + + - name: Build ProtoFleet client + working-directory: ./client + run: | + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + export VITE_VERSION="dev-${{ github.sha }}" + export VITE_BUILD_DATE="$BUILD_DATE" + export VITE_COMMIT="${{ github.sha }}" + npm run build:protoFleet + + - name: Create version file + working-directory: ./client + run: | + echo "version: dev-${{ github.sha }}" > dist/protoFleet/version.txt + echo "branch: ${{ inputs.branch }}" >> dist/protoFleet/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> dist/protoFleet/version.txt + echo "commit: ${{ github.sha }}" >> dist/protoFleet/version.txt + + - name: Package ProtoFleet client + working-directory: ./client + run: | + tar -czf proto-fleet-client-dev.tar.gz dist/protoFleet + + - name: Upload client artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-client-artifact + path: ./client/proto-fleet-client-dev.tar.gz + retention-days: 1 + + build-deployment-bundle: + runs-on: ubuntu-latest + needs: [build-proto-fleet-server, build-proto-fleet-client] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Create deployment directory structure + run: | + mkdir -p deployment/server + mkdir -p deployment/client + + - name: Download server artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-server-artifact + path: /tmp + + - name: Download client artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-client-artifact + path: /tmp + + - name: Extract artifacts + run: | + tar -xzf /tmp/proto-fleet-server-dev.tar.gz -C deployment/server + mkdir -p /tmp/client + tar -xzf /tmp/proto-fleet-client-dev.tar.gz -C /tmp/client + mkdir -p deployment/client/protoFleet + cp -r /tmp/client/dist/protoFleet/* deployment/client/protoFleet/ + + - name: Copy deployment configuration files + run: | + cp deployment-files/client/Dockerfile deployment/client/ + cp deployment-files/client/nginx.http.conf deployment/client/ + cp deployment-files/client/nginx.https.conf deployment/client/ + cp deployment-files/server/Dockerfile deployment/server/ + cp deployment-files/docker-compose.yaml deployment/ + cp server/docker-compose.base.yaml deployment/server/ + cp deployment-files/run-fleet.sh deployment/ + chmod +x deployment/run-fleet.sh + + - name: Create version file + run: | + echo "version: dev-${{ github.sha }}" > deployment/version.txt + echo "branch: ${{ inputs.branch }}" >> deployment/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> deployment/version.txt + echo "commit: ${{ github.sha }}" >> deployment/version.txt + + - name: Package deployment bundle + run: | + tar -czf proto-fleet-deployment.tar.gz deployment + + - name: Upload deployment bundle + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-deployment-bundle + path: proto-fleet-deployment.tar.gz + retention-days: 1 + + deploy-to-pi: + runs-on: [self-hosted, proto-fleet-rpi, '${{ inputs.environment }}'] + needs: [build-deployment-bundle] + environment: ${{ inputs.environment }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.branch }} + + - name: Deploy ProtoFleet to ${{ inputs.environment }} + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle + install_dir: ${{ vars.INSTALL_DIR }} diff --git a/.github/workflows/protofleet-e2e-real-miners-manual.yml b/.github/workflows/protofleet-e2e-real-miners-manual.yml new file mode 100644 index 000000000..0940f2452 --- /dev/null +++ b/.github/workflows/protofleet-e2e-real-miners-manual.yml @@ -0,0 +1,135 @@ +name: ProtoFleet E2E (Real Miners - Manual) + +on: + workflow_dispatch: + inputs: + miner_ips: + description: "Comma-separated list of miner IPs to add in the fleet (e.g., 172.16.2.99,172.16.2.88). Reminder: unpair these miners from any previous fleet before running." + required: true + type: string + +concurrency: + group: protofleet-e2e-real-miners + cancel-in-progress: false + +jobs: + e2e-real-miners: + timeout-minutes: 60 + runs-on: [self-hosted, fleet-testing] + env: + # Use a dedicated compose project name so this workflow doesn't interfere + # with other compose stacks that might be running on the machine. + COMPOSE_PROJECT_NAME: protofleet-e2e-real + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Activate hermit + uses: cashapp/activate-hermit@12a728b03ad41eace0f9abaf98a035e7e8ea2318 # v1.1.4 + + - name: Cleanup + shell: bash + run: | + set -euo pipefail + + if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: this workflow expects a Linux self-hosted runner." >&2 + exit 1 + fi + + # Clean up our workflow stack and prune unused resources + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml down -v --remove-orphans || true + docker system prune -f + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Install Playwright browsers + working-directory: client/e2eTests/protoFleet + run: npx playwright install chromium + + - name: Build ProtoFleet + working-directory: client + run: npm run build:protoFleet + + - name: Build plugins for runner (Linux) + shell: bash + run: | + set -euo pipefail + + arch="$(uname -m)" + case "$arch" in + x86_64) goarch="amd64" ;; + aarch64|arm64) goarch="arm64" ;; + *) echo "Unsupported runner arch: $arch"; exit 1 ;; + esac + + go work sync + mkdir -p server/plugins + (cd plugin/proto && CGO_ENABLED=0 GOOS=linux GOARCH="$goarch" go build -o ../../server/plugins/proto-plugin .) + (cd plugin/antminer && CGO_ENABLED=0 GOOS=linux GOARCH="$goarch" go build -o ../../server/plugins/antminer-plugin .) + chmod +x server/plugins/proto-plugin server/plugins/antminer-plugin + + - name: Start all services (DB + API + Frontend) + shell: bash + run: | + set -euo pipefail + # --wait respects healthchecks and depends_on, ensuring proper startup order + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml up -d --wait --wait-timeout 120 + + - name: Run Playwright tests (desktop) + working-directory: client/e2eTests/protoFleet + env: + CI: true + E2E_TARGET: real + E2E_MINER_IPS: ${{ inputs.miner_ips }} + run: npx playwright test --grep @real --project=desktop + + - name: Upload Playwright report + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report-protofleet-real + path: client/e2eTests/protoFleet/playwright-report/ + retention-days: 30 + + - name: Upload Playwright test results + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-results-protofleet-real + path: client/e2eTests/protoFleet/test-results/ + retention-days: 30 + + - name: Dump logs on failure + if: failure() + shell: bash + run: | + set -euo pipefail + + echo "=== Container Status ===" + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml ps -a + echo "" + + echo "=== All Service Logs ===" + docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml logs --tail=100 + echo "" + + echo "=== Network Information ===" + docker network ls + echo "" + + echo "=== Port Bindings ===" + ss -tlnp 2>/dev/null | grep -E ':(4000|5432|8080)' || echo "No services listening on expected ports" + + - name: Environment status + if: always() + shell: bash + run: | + echo "Environment left running for debugging" + echo "Frontend: http://127.0.0.1:8080/" + echo "Fleet API: http://localhost:4000/" + echo "" + echo "To cleanup:" + echo " docker compose -f deployment-files/docker-compose.protofleet-real-miners-runner.yaml down -v" diff --git a/.github/workflows/protofleet-e2e-tests.yml b/.github/workflows/protofleet-e2e-tests.yml new file mode 100644 index 000000000..3afdce340 --- /dev/null +++ b/.github/workflows/protofleet-e2e-tests.yml @@ -0,0 +1,406 @@ +name: ProtoFleet E2E Tests + +on: + pull_request: + paths: + - ".github/workflows/protofleet-e2e-tests.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "client/.npmrc" + - "client/e2eTests/protoFleet/**" + - "client/package.json" + - "client/package-lock.json" + - "client/postcss.config.js" + - "client/public/**" + - "client/src/protoFleet/**" + - "client/src/shared/**" + - "client/vite.config.ts" + - "client/vitePlugins/**" + - "client/tsconfig.json" + - "client/tsconfig.node.json" + - "go.work" + - "go.work.sum" + - "hermit-packages/**" + - "plugin/antminer/**" + - "plugin/proto/**" + - "server/**" + schedule: + # Run daily at 6 AM UTC on main branch + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + detect-specs: + runs-on: ubuntu-latest + outputs: + setup_specs: ${{ steps.detect.outputs.setup_specs }} + specs: ${{ steps.detect.outputs.specs }} + projects: ${{ steps.detect.outputs.projects }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Detect spec files and projects + id: detect + run: | + cd client/e2eTests/protoFleet/spec + + SETUP_SPEC_FILES=$( (find . -name "*.spec.ts" | sed 's|^\./||' | sort | grep -E '^[0-9]{2}-.*\.spec\.ts$' || true) | jq -R . | jq -cs .) + SPEC_FILES=$( (find . -name "*.spec.ts" | sed 's|^\./||' | sort | grep -Ev '^[0-9]{2}-.*\.spec\.ts$' || true) | jq -R . | jq -cs .) + PROJECTS='["desktop", "mobile"]' + + echo "setup_specs=${SETUP_SPEC_FILES}" >> $GITHUB_OUTPUT + echo "specs=${SPEC_FILES}" >> $GITHUB_OUTPUT + echo "projects=${PROJECTS}" >> $GITHUB_OUTPUT + + echo "Detected setup specs: ${SETUP_SPEC_FILES}" + echo "Detected specs: ${SPEC_FILES}" + echo "Projects to run: ${PROJECTS}" + + e2e-tests: + needs: detect-specs + timeout-minutes: 40 + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + spec: ${{ fromJson(needs.detect-specs.outputs.specs) }} + project: ${{ fromJson(needs.detect-specs.outputs.projects) }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set artifact name + id: artifact + run: | + # Sanitize spec name for use in artifact names + SPEC_NAME=$(echo "${{ matrix.spec }}" | sed 's/\.spec\.ts$//' | sed 's/[^a-zA-Z0-9]/-/g') + echo "spec_name=${SPEC_NAME}" >> $GITHUB_OUTPUT + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + with: + preinstall: "false" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Cache Docker layers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-e2e-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx-e2e- + ${{ runner.os }}-buildx- + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Cache Node modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + client/node_modules + ~/.cache/ms-playwright + key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Install Playwright browsers + working-directory: client/e2eTests/protoFleet + run: npx playwright install --with-deps chromium + + - name: Build ProtoFleet + working-directory: client + run: | + # Skip lint in CI to avoid import order issues from merged main branch + npm run build:protoFleet + + - name: Build plugins for CI (Linux AMD64) + run: | + # CI runners use linux/amd64, not arm64 + # CGO_ENABLED=0 creates static binaries that work in Alpine (musl libc) + echo "Syncing Go workspace..." + go work sync + echo "Building static plugins for Linux AMD64..." + mkdir -p server/plugins + (cd plugin/proto && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../../server/plugins/proto-plugin .) + (cd plugin/antminer && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../../server/plugins/antminer-plugin .) + chmod +x server/plugins/proto-plugin server/plugins/antminer-plugin + echo "Plugins built successfully" + + - name: Start services (TimeScaleDB + Backend + Simulators) + working-directory: server + run: docker compose up -d + + - name: Start frontend + working-directory: client + env: + VITE_VERSION: ${{ github.sha }} + VITE_BUILD_DATE: ${{ github.event.repository.updated_at }} + VITE_COMMIT: ${{ github.sha }} + run: | + # Start frontend in background (already built in earlier step) + npx vite preview --mode protoFleet --port 5173 --host & + VITE_PID=$! + echo "Vite PID: $VITE_PID" + + # Wait for frontend to be ready + echo "Waiting for frontend..." + timeout 60 bash -c 'until curl -f http://localhost:5173 2>/dev/null; do sleep 2; done' + echo "Frontend is ready!" + + - name: Run Playwright tests (${{ matrix.project }} - ${{ matrix.spec }}) + working-directory: client/e2eTests/protoFleet + env: + CI: true + SETUP_SPECS_JSON: ${{ needs.detect-specs.outputs.setup_specs }} + run: | + run_spec() { + local spec_path="$1" + local spec_slug + spec_slug=$(echo "${spec_path}" | sed 's/\.spec\.ts$//' | sed 's/[^a-zA-Z0-9]/-/g') + + echo "Running spec: ${spec_path} on project: ${{ matrix.project }}" + PLAYWRIGHT_BLOB_OUTPUT_FILE="blob-report/${{ matrix.project }}-${{ steps.artifact.outputs.spec_name }}-${spec_slug}.zip" \ + PWTEST_BLOB_DO_NOT_REMOVE=1 \ + npx playwright test --project=${{ matrix.project }} --reporter=blob "spec/${spec_path}" + } + + rm -rf blob-report playwright-report test-results + mkdir -p blob-report + + mapfile -t setup_specs < <(printf '%s' "${SETUP_SPECS_JSON}" | jq -r '.[]') + if [ "${#setup_specs[@]}" -gt 0 ]; then + echo "Running setup specs before target spec:" + printf ' - %s\n' "${setup_specs[@]}" + for spec in "${setup_specs[@]}"; do + run_spec "${spec}" + done + fi + + run_spec "${{ matrix.spec }}" + + echo "Blob reports created:" + find blob-report -maxdepth 2 -type f -print | sort || true + + - name: Upload blob report + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ always() && !cancelled() }} + with: + name: blob-report-${{ matrix.project }}-${{ steps.artifact.outputs.spec_name }} + path: client/e2eTests/protoFleet/blob-report/ + if-no-files-found: warn + retention-days: 30 + + - name: Upload test screenshots + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ failure() && !cancelled() }} + with: + name: screenshots-${{ matrix.project }}-${{ steps.artifact.outputs.spec_name }} + path: client/e2eTests/protoFleet/test-results/ + if-no-files-found: ignore + retention-days: 7 + + - name: Dump backend logs on failure + if: failure() + working-directory: server + run: | + echo "=== Fleet API Logs ===" + docker compose logs fleet-api + echo "=== TimescaleDB Logs ===" + docker compose logs timescaledb + + - name: Cleanup + if: always() + working-directory: server + run: docker compose down -v + + merge-reports: + name: Merge Test Reports + runs-on: ubuntu-latest + needs: [e2e-tests] + if: ${{ always() && !cancelled() }} + permissions: + contents: read + outputs: + desktop-report-path: ${{ steps.merge.outputs.desktop-report-path }} + mobile-report-path: ${{ steps.merge.outputs.mobile-report-path }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + with: + preinstall: "false" + + - name: Cache Node modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + client/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Download all blob reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: blob-report-* + path: /tmp/blob-reports + merge-multiple: false + + - name: Merge blob reports into HTML reports + id: merge + working-directory: client/e2eTests/protoFleet + run: | + echo "Merging blob reports..." + mkdir -p merged-reports + + # Organize blob reports by project + mkdir -p /tmp/desktop-blobs /tmp/mobile-blobs + + # Copy all desktop blob reports + find /tmp/blob-reports -name "blob-report-desktop-*" -type d | while read dir; do + if [ "$(find "$dir" -name "*.zip" | wc -l)" -gt 0 ]; then + cp -r "$dir"/* /tmp/desktop-blobs/ 2>/dev/null || true + fi + done + + # Copy all mobile blob reports + find /tmp/blob-reports -name "blob-report-mobile-*" -type d | while read dir; do + if [ "$(find "$dir" -name "*.zip" | wc -l)" -gt 0 ]; then + cp -r "$dir"/* /tmp/mobile-blobs/ 2>/dev/null || true + fi + done + + # Merge desktop reports + if [ "$(find /tmp/desktop-blobs -name "*.zip" 2>/dev/null | wc -l)" -gt 0 ]; then + echo "Merging desktop reports..." + mkdir -p merged-reports/desktop/test-results + PLAYWRIGHT_HTML_OUTPUT_DIR=merged-reports/desktop/playwright-report \ + PLAYWRIGHT_JUNIT_OUTPUT_FILE=merged-reports/desktop/test-results/results.xml \ + npx playwright merge-reports --reporter=html,junit /tmp/desktop-blobs + echo "desktop-report-path=client/e2eTests/protoFleet/merged-reports/desktop" >> $GITHUB_OUTPUT + else + echo "No desktop blob reports found" + fi + + # Merge mobile reports + if [ "$(find /tmp/mobile-blobs -name "*.zip" 2>/dev/null | wc -l)" -gt 0 ]; then + echo "Merging mobile reports..." + mkdir -p merged-reports/mobile/test-results + PLAYWRIGHT_HTML_OUTPUT_DIR=merged-reports/mobile/playwright-report \ + PLAYWRIGHT_JUNIT_OUTPUT_FILE=merged-reports/mobile/test-results/results.xml \ + npx playwright merge-reports --reporter=html,junit /tmp/mobile-blobs + echo "mobile-report-path=client/e2eTests/protoFleet/merged-reports/mobile" >> $GITHUB_OUTPUT + else + echo "No mobile blob reports found" + fi + + echo "Report merging completed" + ls -la merged-reports/ || true + + - name: Upload merged Playwright reports + id: merged-report-artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report-protofleet-merged + path: client/e2eTests/protoFleet/merged-reports/ + if-no-files-found: warn + retention-days: 30 + + - name: Install Testmo CLI + if: ${{ !cancelled() && github.event_name == 'schedule' }} + run: | + npm install -g @testmo/testmo-cli@1.0.0 + + - name: Submit test results to Testmo (Desktop) + if: ${{ !cancelled() && github.event_name == 'schedule' && steps.merge.outputs.desktop-report-path }} + working-directory: client/e2eTests/protoFleet/merged-reports/desktop + env: + TESTMO_TOKEN: ${{ secrets.TESTMO_TOKEN }} + run: | + # Add XML declaration if missing (Playwright sometimes omits it on CI) + if [ -f "test-results/results.xml" ]; then + if ! head -n 1 test-results/results.xml | grep -q '' | cat - test-results/results.xml > test-results/results-fixed.xml + mv test-results/results-fixed.xml test-results/results.xml + echo "First 2 lines after fix:" + head -n 2 test-results/results.xml + else + echo "XML declaration already present" + fi + + testmo automation:run:submit \ + --instance https://proto.testmo.net \ + --project-id 2 \ + --name "ProtoFleet E2E Tests Desktop - $(date +'%Y-%m-%d %H:%M')" \ + --source "protofleet-e2e-tests-desktop" \ + --results test-results/*.xml + else + echo "No desktop test results found" + fi + + - name: Submit test results to Testmo (Mobile) + if: ${{ !cancelled() && github.event_name == 'schedule' && steps.merge.outputs.mobile-report-path }} + working-directory: client/e2eTests/protoFleet/merged-reports/mobile + env: + TESTMO_TOKEN: ${{ secrets.TESTMO_TOKEN }} + run: | + # Add XML declaration if missing (Playwright sometimes omits it on CI) + if [ -f "test-results/results.xml" ]; then + if ! head -n 1 test-results/results.xml | grep -q '' | cat - test-results/results.xml > test-results/results-fixed.xml + mv test-results/results-fixed.xml test-results/results.xml + echo "First 2 lines after fix:" + head -n 2 test-results/results.xml + else + echo "XML declaration already present" + fi + + testmo automation:run:submit \ + --instance https://proto.testmo.net \ + --project-id 2 \ + --name "ProtoFleet E2E Tests Mobile - $(date +'%Y-%m-%d %H:%M')" \ + --source "protofleet-e2e-tests-mobile" \ + --results test-results/*.xml + else + echo "No mobile test results found" + fi + + - name: Note report availability + env: + MERGED_REPORT_ARTIFACT_URL: ${{ steps.merged-report-artifact.outputs.artifact-url }} + run: | + echo "## 🎭 ProtoFleet E2E Test Reports" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "${MERGED_REPORT_ARTIFACT_URL}" ]]; then + echo "📦 **[Download merged Playwright report artifact](${MERGED_REPORT_ARTIFACT_URL})**" >> "$GITHUB_STEP_SUMMARY" + else + echo "📦 **Merged test reports:** Available in the artifacts section as \`playwright-report-protofleet-merged\`" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/protofleet-example-python-plugin-checks.yml b/.github/workflows/protofleet-example-python-plugin-checks.yml new file mode 100644 index 000000000..a1b662060 --- /dev/null +++ b/.github/workflows/protofleet-example-python-plugin-checks.yml @@ -0,0 +1,55 @@ +name: Example Python Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-example-python-plugin-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "plugin/example-python/**" + - "server/sdk/v1/python/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-example-python-plugin-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "plugin/example-python/**" + - "server/sdk/v1/python/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + example-python-plugin: + name: example-python-plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/example-python + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup venv and install dependencies + run: | + python3.13 -m venv .venv + .venv/bin/pip install --upgrade pip setuptools + .venv/bin/pip install -e "../../server/sdk/v1/python" + .venv/bin/pip install -e ".[dev]" + + - name: Run tests + run: .venv/bin/pytest tests/ -v --cov=example_driver --cov-report=term-missing + + - name: Lint + run: .venv/bin/ruff check example_driver/ tests/ + + - name: Typecheck + run: .venv/bin/mypy example_driver/ diff --git a/.github/workflows/protofleet-proto-checks.yml b/.github/workflows/protofleet-proto-checks.yml new file mode 100644 index 000000000..2b4ace67d --- /dev/null +++ b/.github/workflows/protofleet-proto-checks.yml @@ -0,0 +1,41 @@ +name: Proto Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-proto-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "buf.lock" + - "buf.yaml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "proto/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-proto-checks.yml" + - ".github/actions/hermit-setup/action.yml" + - "buf.lock" + - "buf.yaml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "proto/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + proto-lint: + name: Protobuf Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Lint protobuf definitions + run: buf lint diff --git a/.github/workflows/protofleet-proto-plugin-checks.yml b/.github/workflows/protofleet-proto-plugin-checks.yml new file mode 100644 index 000000000..79355a158 --- /dev/null +++ b/.github/workflows/protofleet-proto-plugin-checks.yml @@ -0,0 +1,92 @@ +name: Proto Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-proto-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/proto/**" + - "server/fake-proto-rig/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-proto-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "just/go-plugin.just" + - "proto/**" + - "plugin/proto/**" + - "server/fake-proto-rig/**" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + proto-plugin: + name: Proto Plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/proto + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: golangci-lint fmt --diff -c .golangci.yaml + - name: Build + run: just build + + - name: Prepare Docker for integration tests + run: | + # Proto integration tests use testcontainers and build the fake-proto-rig + # image from server/fake-proto-rig/Dockerfile. Warming the daemon and + # required base images makes CI failures deterministic. + echo "Waiting for Docker daemon to be ready..." + for i in {1..30}; do + if docker info >/dev/null 2>&1; then + echo "Docker daemon is ready" + break + fi + echo "Attempt $i: Docker daemon not ready, waiting 2 seconds..." + sleep 2 + done + + if ! docker info >/dev/null 2>&1; then + echo "ERROR: Docker daemon did not become ready after 30 attempts; aborting integration test setup." + exit 1 + fi + + docker pull hello-world:latest + docker run --rm hello-world:latest + docker pull ubuntu:22.04 + docker pull golang:1.25.4-alpine + docker pull alpine:3.21 + + - name: Check + run: just check diff --git a/.github/workflows/protofleet-server-checks.yml b/.github/workflows/protofleet-server-checks.yml new file mode 100644 index 000000000..dfd5da2df --- /dev/null +++ b/.github/workflows/protofleet-server-checks.yml @@ -0,0 +1,63 @@ +name: Server Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-server-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "proto/**" + - "server/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-server-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "proto/**" + - "server/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + server: + name: Server + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: golangci-lint fmt --diff -c .golangci.yaml + + - name: Lint + run: just lint + + - name: Build + run: just build + + - name: Start database + run: just db-up + + - name: Test + run: just test diff --git a/.github/workflows/protofleet-virtual-plugin-checks.yml b/.github/workflows/protofleet-virtual-plugin-checks.yml new file mode 100644 index 000000000..766d2b0cd --- /dev/null +++ b/.github/workflows/protofleet-virtual-plugin-checks.yml @@ -0,0 +1,57 @@ +name: Virtual Plugin Checks + +on: + pull_request: + paths: + - ".github/workflows/protofleet-virtual-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/virtual/**" + push: + branches: + - main + paths: + - ".github/workflows/protofleet-virtual-plugin-checks.yml" + - ".github/actions/go-cache-setup/action.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "go.work" + - "go.work.sum" + - "server/go.mod" + - "server/go.sum" + - "server/sdk/**" + - "plugin/virtual/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + virtual-plugin: + name: Virtual Plugin + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin/virtual + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Build + run: go build ./... diff --git a/.github/workflows/protoos-e2e-tests.yml b/.github/workflows/protoos-e2e-tests.yml new file mode 100644 index 000000000..cf8d278c5 --- /dev/null +++ b/.github/workflows/protoos-e2e-tests.yml @@ -0,0 +1,157 @@ +name: ProtoOS E2E Tests + +on: + pull_request: + paths: + - ".github/workflows/protoos-e2e-tests.yml" + - "client/.npmrc" + - "client/e2eTests/protoOS/**" + - "client/package.json" + - "client/package-lock.json" + - "client/postcss.config.js" + - "client/public/**" + - "client/src/protoOS/**" + - "client/src/shared/**" + - "client/tsconfig.json" + - "client/tsconfig.node.json" + - "client/vite.config.ts" + - "client/vitePlugins/**" + - "server/go.mod" + - "server/go.sum" + - "server/fake-proto-rig/**" + schedule: + # Run daily at 6 AM UTC on main branch + - cron: "0 6 * * *" + workflow_dispatch: + +jobs: + e2e-tests: + timeout-minutes: 30 + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + project: [desktop, mobile] + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Build fake-proto-rig simulator + run: | + echo "Building fake-proto-rig simulator..." + docker build \ + -t fake-proto-rig \ + -f server/fake-proto-rig/Dockerfile \ + . + + - name: Start fake-proto-rig simulator + run: | + docker run -d \ + --name fake-proto-rig \ + -p 8080:8080 \ + fake-proto-rig + + - name: Cache Node modules + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + client/node_modules + ~/.cache/ms-playwright + key: ${{ runner.os }}-node-${{ hashFiles('client/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install client dependencies + working-directory: client + run: npm ci + + - name: Build ProtoOS frontend + working-directory: client + run: npm run build:protoOS + + - name: Start ProtoOS frontend + working-directory: client + run: | + npm run preview:protoOS -- --port 3000 --host 0.0.0.0 --strictPort > /tmp/vite-preview.log 2>&1 & + env: + PROXY_URL: http://localhost:8080 + + - name: Install Playwright browsers + working-directory: client/e2eTests/protoOS + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests (${{ matrix.project }}) + working-directory: client/e2eTests/protoOS + run: npx playwright test --project=${{ matrix.project }} + env: + CI: true + + - name: Upload Playwright report + id: playwright-report-artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report-protoos-${{ matrix.project }} + path: client/e2eTests/protoOS/playwright-report/ + retention-days: 30 + + - name: Note report availability + if: ${{ !cancelled() }} + env: + PLAYWRIGHT_REPORT_ARTIFACT_URL: ${{ steps.playwright-report-artifact.outputs.artifact-url }} + run: | + echo "## ProtoOS E2E Report (${{ matrix.project }})" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "${PLAYWRIGHT_REPORT_ARTIFACT_URL}" ]]; then + echo "📦 **[Download Playwright report artifact](${PLAYWRIGHT_REPORT_ARTIFACT_URL})**" >> "$GITHUB_STEP_SUMMARY" + else + echo "📦 **Playwright report:** Available in the artifacts section as \`playwright-report-protoos-${{ matrix.project }}\`" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Install Testmo CLI + if: ${{ !cancelled() && github.event_name == 'schedule' }} + run: | + npm install -g @testmo/testmo-cli@1.0.0 + + - name: Submit test results to Testmo + if: ${{ !cancelled() && github.event_name == 'schedule' }} + working-directory: client/e2eTests/protoOS + env: + TESTMO_TOKEN: ${{ secrets.TESTMO_TOKEN }} + run: | + # Add XML declaration if missing (Playwright sometimes omits it on CI) + if ! head -n 1 test-results/results.xml | grep -q '' | cat - test-results/results.xml > test-results/results-fixed.xml + mv test-results/results-fixed.xml test-results/results.xml + echo "First 2 lines after fix:" + head -n 2 test-results/results.xml + else + echo "XML declaration already present" + fi + + testmo automation:run:submit \ + --instance https://proto.testmo.net \ + --project-id 2 \ + --name "Proto OS E2E Tests - ${{ matrix.project }} - $(date +'%Y-%m-%d %H:%M')" \ + --source "protoos-e2e-tests-${{ matrix.project }}" \ + --results test-results/*.xml + + - name: Upload test screenshots + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: failure() + with: + name: playwright-screenshots-protoos-${{ matrix.project }} + path: client/e2eTests/protoOS/test-results/ + retention-days: 7 + + - name: Dump simulator logs on failure + if: failure() + run: | + echo "=== fake-proto-rig Logs ===" + docker logs fake-proto-rig 2>&1 || echo "No fake-proto-rig logs available" diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 000000000..6cb0021fe --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,32 @@ +name: Pull Request + +on: pull_request_target + +permissions: + contents: read + repository-projects: read + pull-requests: write + +jobs: + triage: + name: Triage + runs-on: ubuntu-latest + + steps: + - name: Apply labels + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + + auto-assign: + name: Auto-Assign + runs-on: ubuntu-latest + if: github.actor != 'dependabot[bot]' + + steps: + - name: Assign PR to Creator + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh pr edit ${{ github.event.pull_request.number }} --add-assignee ${{ github.actor }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 000000000..9db3af5b4 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,140 @@ +name: Python SDK and Generator Checks + +on: + pull_request: + paths: + - ".github/workflows/python-tests.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "packages/proto-python-gen/**" + - "server/buf.gen.yaml" + - "server/buf.yaml" + - "server/sdk/v1/pb/**" + - "server/sdk/v1/python/**" + push: + branches: + - main + paths: + - ".github/workflows/python-tests.yml" + - ".github/actions/hermit-setup/action.yml" + - "bin/hermit.hcl" + - "hermit-packages/**" + - "packages/proto-python-gen/**" + - "server/buf.gen.yaml" + - "server/buf.yaml" + - "server/sdk/v1/pb/**" + - "server/sdk/v1/python/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + proto-python-gen: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/proto-python-gen + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup venv + run: just setup + + - name: Run tests + run: just test + + - name: Lint + run: just lint + + sdk-python: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server/sdk/v1/python + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup venv + run: just setup + + - name: Run checks (tests + typecheck + lint) + run: just check + + python-gen-staleness: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Setup protoc-gen-python-grpc venv + run: just setup + working-directory: packages/proto-python-gen + + - name: Regenerate Python SDK stubs + run: just gen-sdk-protos + working-directory: server + + - name: Check for staleness + run: | + if ! git diff --exit-code server/sdk/v1/python/proto_fleet_sdk/generated/; then + echo "ERROR: Generated Python SDK stubs are out of date." + echo "Run 'cd server && just gen-sdk-protos' and commit the changes." + exit 1 + fi + echo "Generated stubs are up to date." + proto-python-gen-tarball: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/proto-python-gen + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Validate tarball contents match source + run: | + TARBALL="proto-python-gen-0.2.0.tar.gz" + if [[ ! -f "${TARBALL}" ]]; then + echo "ERROR: Committed tarball ${TARBALL} not found." + echo "Run 'cd packages/proto-python-gen && just package' and commit the tarball." + exit 1 + fi + + COMMITTED="$(mktemp -d)" + EXPECTED="$(mktemp -d)" + trap 'rm -rf "${COMMITTED}" "${EXPECTED}"' EXIT + + tar -xzf "${TARBALL}" -C "${COMMITTED}" + + mkdir -p "${EXPECTED}/bin" + cp bin/protoc-gen-python-grpc "${EXPECTED}/bin/" + cp protoc_gen_python_grpc.py "${EXPECTED}/" + cp setup.sh "${EXPECTED}/" + cp ../../scripts/pip-config.sh "${EXPECTED}/" + cp requirements.txt "${EXPECTED}/" + + if diff -r "${COMMITTED}" "${EXPECTED}" > /dev/null 2>&1; then + echo "Tarball contents match source files." + else + echo "ERROR: Committed tarball does not match source files." + diff -r "${COMMITTED}" "${EXPECTED}" || true + echo "" + echo "Run 'cd packages/proto-python-gen && just package' and commit the updated tarball." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..d876f1363 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,538 @@ +name: Create ProtoFleet Release Artifacts + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + pull-requests: read + +jobs: + validate-tag: + runs-on: ubuntu-latest + steps: + - name: Validate tag format + env: + TAG_NAME: ${{ github.ref_name }} + run: | + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$ ]]; then + echo "::error::Tag '$TAG_NAME' does not match expected format (e.g., v1.2.3 or v1.2.3-rc.1)" + exit 1 + fi + echo "Tag '$TAG_NAME' is valid" + + - name: Require full releases to be on main + if: ${{ !contains(github.ref_name, '-') }} + env: + GH_TOKEN: ${{ github.token }} + run: | + AHEAD_BY=$(gh api "repos/${{ github.repository }}/compare/main...${{ github.sha }}" --jq '.ahead_by') + if [[ "$AHEAD_BY" -ne 0 ]]; then + echo "::error::Full releases are only allowed from commits on the main branch. Tag '${{ github.ref_name }}' points to a commit not on main." + exit 1 + fi + echo "Full release tag is on main — OK" + + build-proto-fleet-windows-installer: + runs-on: windows-latest + needs: [validate-tag] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup .NET SDK from global.json + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: deployment-files/windows/global.json + + - name: Show .NET SDK info + shell: powershell + run: dotnet --info + + - name: Install ps2exe + shell: powershell + run: | + Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted + Install-Module -Name ps2exe -Force -Scope CurrentUser + + - name: Build Windows installer EXE + shell: powershell + working-directory: ./deployment-files/windows + run: | + $outputDir = Join-Path $PWD "artifacts/release-installer" + ./build-fleet-installer.ps1 -Configuration Release -OutputDir $outputDir + $installerPath = Join-Path $outputDir "installer.exe" + if (-not (Test-Path $installerPath)) { + throw "Installer build completed but installer.exe was not found in $outputDir." + } + Write-Host "Windows installer prepared at $installerPath" + + - name: Build Windows uninstaller EXE + shell: powershell + working-directory: ./deployment-files/windows + run: | + $outputDir = Join-Path $PWD "artifacts/release-installer" + $uninstallerPath = Join-Path $outputDir "uninstall.exe" + ./build-fleet-uninstaller-exe.ps1 -OutputExe $uninstallerPath + if (-not (Test-Path $uninstallerPath)) { + throw "Uninstaller build completed but uninstall.exe was not found at $uninstallerPath." + } + Write-Host "Windows uninstaller prepared at $uninstallerPath" + + - name: Upload Windows installer artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-windows-installer + path: | + ./deployment-files/windows/artifacts/release-installer/installer.exe + ./deployment-files/windows/artifacts/release-installer/uninstall.exe + retention-days: 1 + + build-proto-fleet-server: + needs: [validate-tag] + env: + TAG_NAME: ${{ github.ref_name }} + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Configure Go cache + uses: ./.github/actions/go-cache-setup + + - name: Build Golang server (${{ matrix.arch }}) + working-directory: ./server + run: | + go mod download + go build -v -o fleetd-${{ matrix.arch }} ./cmd/fleetd + echo "version: $TAG_NAME" > version.txt + if [[ "$TAG_NAME" == *-* ]]; then IS_PRERELEASE=true; else IS_PRERELEASE=false; fi + echo "is_prerelease: $IS_PRERELEASE" >> version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> version.txt + echo "commit: ${{ github.sha }}" >> version.txt + + - name: Build Go plugin binaries (${{ matrix.arch }}) + run: | + echo "Syncing Go workspace..." + go work sync + echo "Building Go plugins for ${{ matrix.arch }}..." + go build -o server/proto-plugin-${{ matrix.arch }} ./plugin/proto + go build -o server/antminer-plugin-${{ matrix.arch }} ./plugin/antminer + chmod +x server/proto-plugin-* server/antminer-plugin-* + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build asicrs plugin binary (${{ matrix.arch }}) + run: | + docker buildx build \ + --file plugin/asicrs/Dockerfile.build \ + --output "type=local,dest=/tmp/asicrs" \ + . + cp "/tmp/asicrs/asicrs-plugin" "server/asicrs-plugin-${{ matrix.arch }}" + cp "/tmp/asicrs/asicrs-config.yaml" "server/asicrs-config.yaml" + chmod +x server/asicrs-plugin-* + echo "asicrs plugin built successfully for ${{ matrix.arch }}" + + - name: Package server binaries (${{ matrix.arch }}) + working-directory: ./server + run: | + tar -czf "proto-fleet-server-${TAG_NAME}-${{ matrix.arch }}.tar.gz" \ + fleetd-${{ matrix.arch }} \ + proto-plugin-${{ matrix.arch }} \ + antminer-plugin-${{ matrix.arch }} \ + asicrs-plugin-${{ matrix.arch }} \ + asicrs-config.yaml \ + version.txt + + - name: Upload server artifact for job use + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-server-artifact-${{ matrix.arch }} + path: ./server/proto-fleet-server-${{ github.ref_name }}-${{ matrix.arch }}.tar.gz + retention-days: 1 + + build-proto-fleet-client: + runs-on: ubuntu-latest + needs: [validate-tag] + env: + TAG_NAME: ${{ github.ref_name }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + cache: "npm" + cache-dependency-path: "./client/package-lock.json" + + - name: Install dependencies + working-directory: ./client + run: npm ci + + - name: Build ProtoFleet client + working-directory: ./client + run: | + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + export VITE_VERSION="$TAG_NAME" + export VITE_BUILD_DATE="$BUILD_DATE" + export VITE_COMMIT="${{ github.sha }}" + npm run build:protoFleet + + - name: Create version file + working-directory: ./client + run: | + echo "version: $TAG_NAME" > dist/protoFleet/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> dist/protoFleet/version.txt + echo "commit: ${{ github.sha }}" >> dist/protoFleet/version.txt + + - name: Package ProtoFleet client + working-directory: ./client + run: | + tar -czf "proto-fleet-client-${TAG_NAME}.tar.gz" dist/protoFleet + + - name: Upload client artifact for job use + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-client-artifact + path: ./client/proto-fleet-client-${{ github.ref_name }}.tar.gz + retention-days: 1 + + build-proto-os: + runs-on: ubuntu-latest + needs: [validate-tag] + env: + TAG_NAME: ${{ github.ref_name }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Hermit + uses: ./.github/actions/hermit-setup + + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + cache: "npm" + cache-dependency-path: "./client/package-lock.json" + + - name: Install dependencies + working-directory: ./client + run: npm ci + + - name: Build ProtoOS + working-directory: ./client + run: | + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + export VITE_VERSION="$TAG_NAME" + export VITE_BUILD_DATE="$BUILD_DATE" + export VITE_COMMIT="${{ github.sha }}" + npm run build:protoOS + + - name: Create version file + working-directory: ./client + run: | + echo "version: $TAG_NAME" > dist/protoOS/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> dist/protoOS/version.txt + echo "commit: ${{ github.sha }}" >> dist/protoOS/version.txt + + - name: Package ProtoOS + working-directory: ./client + run: | + tar -czf "proto-os-${TAG_NAME}.tar.gz" dist/protoOS + + - name: Create version file for control board package .ipk + working-directory: ./client + run: echo "$TAG_NAME" > dist/web_dashboard_version + + - name: Build ProtoOS .ipk package + working-directory: ./client + run: | + nfpm package --config nfpm-proto-os.yaml --packager ipk --target "proto-os_${VERSION}.ipk" + env: + VERSION: ${{ github.ref_name }} + + - name: Upload ProtoOS artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-os-artifact + path: | + ./client/proto-os-${{ github.ref_name }}.tar.gz + ./client/proto-os_${{ github.ref_name }}.ipk + retention-days: 1 + + build-timescaledb-image: + needs: [validate-tag] + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Build TimescaleDB image (${{ matrix.arch }}) + run: | + docker buildx build \ + -t proto-fleet-timescaledb:latest \ + --output type=docker,dest=timescaledb-${{ matrix.arch }}.tar \ + server/timescaledb + gzip -9 timescaledb-${{ matrix.arch }}.tar + echo "${{ matrix.arch }} image size: $(du -h timescaledb-${{ matrix.arch }}.tar.gz | cut -f1)" + + - name: Upload TimescaleDB image artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-timescaledb-${{ matrix.arch }} + path: timescaledb-${{ matrix.arch }}.tar.gz + retention-days: 1 + + build-proto-fleet: + runs-on: ubuntu-latest + needs: [build-proto-fleet-server, build-proto-fleet-client, build-proto-fleet-windows-installer, build-timescaledb-image] + env: + TAG_NAME: ${{ github.ref_name }} + strategy: + matrix: + arch: [amd64, arm64] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Create deployment directory structure + run: | + mkdir -p deployment/server + mkdir -p deployment/client + + - name: Download server artifact (${{ matrix.arch }}) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-server-artifact-${{ matrix.arch }} + path: /tmp/server-artifacts + + - name: Download client artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-client-artifact + path: /tmp + + - name: Download Windows installer artifact + if: matrix.arch == 'amd64' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-windows-installer + path: /tmp/windows-installer + + - name: Download TimescaleDB image artifact (${{ matrix.arch }}) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-timescaledb-${{ matrix.arch }} + path: /tmp/timescaledb-images + + - name: Extract artifacts + run: | + # Extract server artifacts and rename to generic names (no arch suffix) + tar -xzf "/tmp/server-artifacts/proto-fleet-server-${TAG_NAME}-${{ matrix.arch }}.tar.gz" -C deployment/server + cd deployment/server + mv fleetd-${{ matrix.arch }} fleetd + mv proto-plugin-${{ matrix.arch }} proto-plugin + mv antminer-plugin-${{ matrix.arch }} antminer-plugin + mv asicrs-plugin-${{ matrix.arch }} asicrs-plugin + cd ../.. + + # Extract client + mkdir -p /tmp/client + tar -xzf "/tmp/proto-fleet-client-${TAG_NAME}.tar.gz" -C /tmp/client + mkdir -p deployment/client/protoFleet + cp -r /tmp/client/dist/protoFleet/* deployment/client/protoFleet/ + + # Bundle Windows installer in amd64 tarball only + if [ "${{ matrix.arch }}" = "amd64" ]; then + mkdir -p deployment/install + cp /tmp/windows-installer/installer.exe deployment/install/installer.exe + cp /tmp/windows-installer/uninstall.exe deployment/install/uninstall.exe + fi + + - name: Copy deployment configuration files + run: | + cp deployment-files/client/Dockerfile deployment/client/ + cp deployment-files/client/nginx.http.conf deployment/client/ + cp deployment-files/client/nginx.https.conf deployment/client/ + cp deployment-files/server/Dockerfile deployment/server/ + cp deployment-files/docker-compose.yaml deployment/ + cp server/docker-compose.base.yaml deployment/server/ + cp deployment-files/run-fleet.sh deployment/ + cp deployment-files/uninstall.sh deployment/ + cp -r deployment-files/scripts deployment/ + chmod +x deployment/run-fleet.sh deployment/uninstall.sh deployment/scripts/*.sh + + # Pre-built TimescaleDB Docker image for this architecture + mkdir -p deployment/images + cp /tmp/timescaledb-images/timescaledb-${{ matrix.arch }}.tar.gz deployment/images/timescaledb.tar.gz + + - name: Create version file + run: | + echo "version: $TAG_NAME" > deployment/version.txt + echo "build_date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> deployment/version.txt + echo "commit: ${{ github.sha }}" >> deployment/version.txt + + - name: Package ProtoFleet deployment bundle (${{ matrix.arch }}) + run: | + tar -czf "proto-fleet-${TAG_NAME}-${{ matrix.arch }}.tar.gz" deployment + + - name: Upload deployment bundle artifact + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: proto-fleet-deployment-bundle-${{ matrix.arch }} + path: proto-fleet-${{ github.ref_name }}-${{ matrix.arch }}.tar.gz + retention-days: 1 + + publish-proto-fleet: + runs-on: ubuntu-latest + needs: [build-proto-fleet, build-proto-os] + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download deployment bundle artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: proto-fleet-deployment-bundle-* + path: /tmp/release-assets/bundles + merge-multiple: true + + - name: Download Windows installer artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-windows-installer + path: /tmp/release-assets/windows + + - name: Download server artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: proto-fleet-server-artifact-* + path: /tmp/release-assets/server + merge-multiple: true + + - name: Download client artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-fleet-client-artifact + path: /tmp/release-assets/client + + - name: Download ProtoOS artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: proto-os-artifact + path: /tmp/release-assets/proto-os + + - name: List all release assets + run: find /tmp/release-assets -type f | sort + + - name: Create draft release and upload assets + id: create_release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + draft: true + prerelease: ${{ contains(github.ref_name, '-') }} + generate_release_notes: true + files: | + /tmp/release-assets/bundles/proto-fleet-${{ github.ref_name }}-*.tar.gz + /tmp/release-assets/windows/installer.exe + /tmp/release-assets/windows/uninstall.exe + /tmp/release-assets/server/proto-fleet-server-${{ github.ref_name }}-*.tar.gz + /tmp/release-assets/client/proto-fleet-client-${{ github.ref_name }}.tar.gz + /tmp/release-assets/proto-os/proto-os-${{ github.ref_name }}.tar.gz + /tmp/release-assets/proto-os/proto-os_${{ github.ref_name }}.ipk + ./deployment-files/install.sh + ./deployment-files/uninstall.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "${{ github.ref_name }}" --draft=false + + deploy-to-pi-mar: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-mar'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-mar + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-mar + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} + + deploy-to-pi-stl: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-stl'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-stl + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-stl + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} + + deploy-to-pi-fxsj: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-fxsj'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-fxsj + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-fxsj + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} + + deploy-to-pi-dalton: + runs-on: [self-hosted, proto-fleet-rpi, 'pi-dalton'] + needs: [publish-proto-fleet] + timeout-minutes: 30 + environment: pi-dalton + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Deploy ProtoFleet to pi-dalton + uses: ./.github/actions/deploy-protofleet + with: + artifact_name: proto-fleet-deployment-bundle-arm64 + install_dir: ${{ vars.INSTALL_DIR }} diff --git a/.github/workflows/rust-sdk-checks.yml b/.github/workflows/rust-sdk-checks.yml new file mode 100644 index 000000000..3beeb5ca5 --- /dev/null +++ b/.github/workflows/rust-sdk-checks.yml @@ -0,0 +1,105 @@ +name: Rust SDK Checks + +on: + pull_request: + paths: + - ".github/workflows/rust-sdk-checks.yml" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + push: + branches: + - main + paths: + - ".github/workflows/rust-sdk-checks.yml" + - "sdk/rust/**" + - "server/sdk/v1/pb/driver.proto" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + rust-lint: + name: Rust Lint & Format + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdk/rust/proto-fleet-plugin + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt, clippy + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + sdk/rust/proto-fleet-plugin/target + key: ${{ runner.os }}-cargo-${{ hashFiles('sdk/rust/proto-fleet-plugin/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt -- --check + + - name: Clippy (default features) + run: cargo clippy -- -D warnings + + - name: Clippy (http-client feature) + run: cargo clippy --features http-client -- -D warnings + + rust-build-test: + name: Rust Build & Test + runs-on: ubuntu-latest + needs: [rust-lint] + defaults: + run: + working-directory: sdk/rust/proto-fleet-plugin + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: Install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache Cargo registry & build + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + sdk/rust/proto-fleet-plugin/target + key: ${{ runner.os }}-cargo-${{ hashFiles('sdk/rust/proto-fleet-plugin/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build (default features) + run: cargo build + + - name: Build (http-client feature) + run: cargo build --features http-client + + - name: Test (default features) + run: cargo test + + - name: Test (http-client feature) + run: cargo test --features http-client diff --git a/.github/workflows/windows-csharp-checks.yml b/.github/workflows/windows-csharp-checks.yml new file mode 100644 index 000000000..a58e78cdf --- /dev/null +++ b/.github/workflows/windows-csharp-checks.yml @@ -0,0 +1,70 @@ +name: Windows C# Checks + +on: + pull_request: + paths: + - ".github/workflows/windows-csharp-checks.yml" + - "deployment-files/windows/**" + push: + branches: + - main + paths: + - ".github/workflows/windows-csharp-checks.yml" + - "deployment-files/windows/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + quality: + name: Format, Build, and Package + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup .NET SDK from global.json + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: deployment-files/windows/global.json + + - name: Show .NET SDK info + shell: powershell + run: dotnet --info + + - name: Install ps2exe + shell: powershell + run: | + Set-PSRepository -Name "PSGallery" -InstallationPolicy Trusted + Install-Module -Name ps2exe -Force -Scope CurrentUser + + - name: Restore installer solution + shell: powershell + run: dotnet restore deployment-files/windows/ProtoFleet.Installer.sln --configfile deployment-files/windows/NuGet.Config + + - name: Check formatting + shell: powershell + run: dotnet format deployment-files/windows/ProtoFleet.Installer.sln --verify-no-changes --no-restore + + - name: Build installer solution + shell: powershell + run: dotnet build deployment-files/windows/ProtoFleet.Installer.sln -c Release --no-restore + + - name: Validate installer and uninstaller packaging + shell: powershell + working-directory: ./deployment-files/windows + run: | + $outputDir = Join-Path $env:RUNNER_TEMP "proto-fleet-installer" + ./build-fleet-installer.ps1 -Configuration Release -OutputDir $outputDir + $installerPath = Join-Path $outputDir "installer.exe" + if (-not (Test-Path $installerPath)) { + throw "No installer executable produced at $installerPath." + } + $uninstallerPath = Join-Path $outputDir "uninstall.exe" + ./build-fleet-uninstaller-exe.ps1 -OutputExe $uninstallerPath + if (-not (Test-Path $uninstallerPath)) { + throw "No uninstaller executable produced at $uninstallerPath." + } + Write-Host "Validated installer package: $installerPath" + Write-Host "Validated uninstaller package: $uninstallerPath" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..97a562fab --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +.hermit/ +.idea +.DS_Store +node_modules/ +server/influx_config/.env +**/.goose/ +.claude/ +.mcp.json +lefthook-local.yml + +**private.env** +.env* + +# Binary files +server/cmd/fleetd/fleetd +server/fake-antminer/fake-antminer +server/fake-proto-rig/fake-proto-rig +server/miner-debug-cli +server/devtools/seedtelemetry/seedtelemetry + +# Plugin binaries (built artifacts should not be committed) +plugin/proto/proto-plugin +plugin/antminer/antminer-plugin +plugin/virtual/virtual +plugin/asicrs/target/ + +server/plugins/* + +# Generated nginx config (copied from nginx.http.conf or nginx.https.conf by run-fleet.sh) +deployment-files/client/nginx.conf + +# proto-python-gen package artifacts +packages/proto-python-gen/.venv +packages/proto-python-gen/tests/output*/ +server/sdk/v1/python/.venv +server/plugins-active/ +server/docker-compose.plugins.yaml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..84292128c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + // Editor + "files.exclude": { + "**/target/": true, + "**/vendor/": true, + "**/.hermit/": true, + "**/docs/site/": true, + "**/sstate-cache/": true, + "**/artifacts/": true, + "**/build/": true + }, + "git.autoRepositoryDetection": false, + "git.detectSubmodules": false, + "search.exclude": { + "**/.git": true + }, + "editor.formatOnSave": true, + "files.insertFinalNewline": true, + + // Hermit + "terminal.integrated.env.osx": { + "ACTIVE_HERMIT": null, + "HERMIT_ENV": null, + "HERMIT_ENV_OPS": null, + "HERMIT_BIN": null + }, + + // Protobuf + "protoc": { + "path": "protoc", + "compile_flags": [ + "--proto_path=proto" + ] + }, + "clangd.arguments": [ + "--proto-path=proto" + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..b8e821860 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,135 @@ + +# Block Code of Conduct + +Block's mission is Economic Empowerment. This means opening the global economy to everyone. We extend the same principles of inclusion to our developer ecosystem. We are excited to build with you. So we will ensure our community is truly open, transparent and inclusive. Because of the global nature of our project, diversity and inclusivity is paramount to our success. We not only welcome diverse perspectives, we **need** them! + +The code of conduct below reflects the expectations for ourselves and for our community. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, physical appearance, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful and welcoming of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +The Block Open Source Governance Committee (GC) is responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +The GC has the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event, or any space where the project is listed as part of your profile. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the Block Open Source Governance Committee (GC) at +`open-source-governance@block.xyz`. All complaints will be reviewed and +investigated promptly and fairly. + +The GC is obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +The GC will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from the GC, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media and forums. + +Although this list cannot be exhaustive, we explicitly honor diversity in age, culture, ethnicity, gender identity or expression, language, national origin, political beliefs, profession, race, religion, sexual orientation, socioeconomic status, and technical ability. We will not tolerate discrimination based on any of the protected characteristics above, including participants with disabilities. + +Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..16f2d6e0a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,222 @@ +# Contributing to Proto Fleet + +Thank you for your interest in contributing to Proto Fleet! This guide covers the development workflows and conventions used in this project. + +## Reporting Issues + +We use GitHub issue templates to keep reports consistent and actionable. When you open a new issue you will see an issue chooser with these options: + +- **Bug Report** — something is broken or behaving unexpectedly +- **Feature Request** — suggest a new feature or improvement +- **Miner Compatibility Issue** — report a problem with a specific miner model or firmware version + +If you have a question or want to start a discussion, head to [GitHub Discussions](https://github.com/block/proto-fleet/discussions) instead. For security vulnerabilities, follow the process described in [SECURITY.md](SECURITY.md). + +## Development Setup + +Start with the [README](README.md) for the basic development flow, then use the details here for contributor-specific setup requirements. + +### Hermit Setup + +If you use Hermit, activate the managed toolchain and install project dependencies: + +```bash +source ./bin/activate-hermit +just setup +``` + +After your toolchain is ready, install Git hooks: + +```bash +just install-hooks +``` + +### Non-Hermit Setup + +If you are not using Hermit, install the required toolchain yourself before running project tasks. The repository recipes and hooks expect these binaries to be available in `PATH` as needed: + +- `just` for top-level and per-project task runners +- `go` for server and Go plugin workflows +- `node` and `npm` for client setup, linting, testing, and Storybook +- `buf` for protobuf linting and generation +- `lefthook` for Git hook installation +- `golangci-lint` for Go linting and pre-push checks +- `goimports` for Go formatting and code generation follow-up +- `sqlc` for generating server query bindings +- `migrate` for creating and running database migrations + +Python-specific tooling depends on the files you change: + +- `packages/proto-python-gen`: `cd packages/proto-python-gen && just setup-dev` +- `server/sdk/v1/python`: `cd server/sdk/v1/python && just setup` +- `plugin/example-python` and other Python paths: install `ruff` in `PATH`, or set `PROTO_FLEET_RUFF=/path/to/ruff` + +## Git Hooks + +Install Git hooks with: + +```bash +just install-hooks +``` + +If `lefthook` is not installed, `just install-hooks` will fail. Hermit users can run `source ./bin/activate-hermit` first to make `lefthook` available. Non-Hermit users need to install `lefthook` manually, then rerun `just install-hooks`. + +### Python Hook Prerequisites + +The pre-commit hooks run Ruff for staged Python files. Make sure the relevant Ruff environment is available before committing Python changes: + +- `packages/proto-python-gen`: `cd packages/proto-python-gen && just setup-dev` +- `server/sdk/v1/python`: `cd server/sdk/v1/python && just setup` +- `plugin/example-python`: install `ruff` in `PATH`, or set `PROTO_FLEET_RUFF=/path/to/ruff` +- Other Python paths: install `ruff` in `PATH`, or set `PROTO_FLEET_RUFF=/path/to/ruff` + +### Pre-Push Checks + +The pre-push hooks also run repository checks before a branch can be pushed: + +- `client`: TypeScript typechecking via `npm exec --no -- tsc --noEmit` +- `server`: `golangci-lint run -c .golangci.yaml` +- `plugin/proto`: `golangci-lint run -c .golangci.yaml` +- `plugin/antminer`: `golangci-lint run -c .golangci.yaml` + +## Git Workflow + +### Branch Naming + +Create feature branches with descriptive names: + +```bash +git checkout -b /short-description +``` + +### Commit Messages + +Follow [conventional commit](https://www.conventionalcommits.org/) format: + +```bash +git commit -m "feat: add telemetry streaming to fleet UI + +- Implement server-to-client streaming connection +- Add telemetry slice to fleet store +- Update MinerList to display live metrics" +``` + +Prefixes: + +- `feat:` — New feature +- `fix:` — Bug fix +- `refactor:` — Code refactoring +- `docs:` — Documentation changes +- `test:` — Test additions or updates +- `chore:` — Build/tooling changes + +### Pull Requests + +Create PRs with a clear summary and test plan: + +```bash +gh pr create --title "Brief description" --body "## Summary +- Bullet point summary of changes + +## Test Plan +- How to verify the changes work" +``` + +## Cross-Component Workflows + +### Adding a New API Endpoint + +1. Define the API in the appropriate `.proto` file in `proto/` +2. Run `just gen` to regenerate TypeScript and Go code +3. Implement the server handler in `server/internal/handlers/` +4. Register the handler in `server/cmd/fleetd/main.go` +5. Create a client hook in `client/src/{app}/api/` +6. Update the Zustand store slice to consume the data +7. Commit proto definitions and all generated code together + +### Making Database Schema Changes + +1. Create a migration: `cd server && just db-migration-new ` +2. Write both up and down migrations in `server/migrations/` +3. Run `just gen` to regenerate sqlc bindings +4. Update queries in `server/sqlc/queries/` if needed +5. **Never modify existing migrations after they have been deployed** + +### Adding Features to the Client + +1. Determine the target app: ProtoOS, ProtoFleet, or shared +2. Check `client/src/shared/components/` for existing reusable components +3. Place the feature in the appropriate `client/src/{app}/features/` directory +4. Create Storybook stories for new components +5. Write tests with Vitest and Testing Library + +### Adding Business Logic to the Server + +1. Add domain logic to the appropriate package in `internal/domain/` +2. Create a gRPC handler in `internal/handlers/` +3. Add tests for domain logic and handlers +4. Update stores in `internal/domain/stores/sqlstores/` if database access is needed + +## Code Generation + +All generated code must be committed to Git. Run `just gen` after: + +- Modifying protobuf definitions in `proto/` +- Changing database migrations in `server/migrations/` +- Adding or modifying sqlc queries in `server/sqlc/queries/` + +Never manually edit generated files in: + +- `client/src/protoOS/api/generatedApi.ts` +- `client/src/protoFleet/api/generated/` +- `server/generated/` + +## Component Boundaries + +Maintain strict separation between applications: + +- Code in `client/src/shared/` must not import from ProtoOS or ProtoFleet +- ProtoOS and ProtoFleet must not import from each other +- Server code is completely independent of client code + +This ensures applications remain decoupled and shared code stays truly reusable. + +## Go Workspace + +The repository uses a Go workspace (`go.work`) for integrated development: + +- All Go modules (server and plugins) are included in the workspace +- Changes across modules are immediately available without version bumps +- Both `go.work` and `go.work.sum` are committed to Git for reproducible builds +- Run `go work sync` after updating dependencies + +## Testing + +### Client + +```bash +cd client +npm test # Run all tests +npx vitest run # Run tests matching a pattern +npx vitest watch # Watch mode for a specific file +npm run storybook # Visual component testing +``` + +### Server + +```bash +cd server +just test # Run all tests +just lint # Lint code +go test ./internal/domain/pairing -v # Test a specific package +go test ./internal/domain/pairing -v -run TestName # Run a specific test +``` + +### E2E Tests + +```bash +cd server +go test -tags=e2e ./e2e # Run e2e tests (requires docker-compose) +``` + +See `server/e2e/README.md` for the full e2e testing guide. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 6bccf4336..aeba40c4a 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1 +1,59 @@ -## [Click here for Block Open Source Project governance information](https://github.com/block/.github/blob/main/GOVERNANCE.md) \ No newline at end of file +# Block Open Source Project Governance + + + +* [Contributors](#contributors) +* [Maintainers](#maintainers) +* [Governance Committee](#governance-committee) + + + +## Contributors + +Anyone may be a contributor to Block open source projects. Contribution may take the form of: + +* Asking and answering questions on the Discord or GitHub Issues +* Filing an issue +* Offering a feature or bug fix via a Pull Request +* Suggesting documentation improvements +* ...and more! + +Anyone with a GitHub account may use the project issue trackers and communications channels. We welcome newcomers, so don't hesitate to say hi! + +## Maintainers + +Maintainers have write access to GitHub repositories and act as project administrators. They approve and merge pull requests, cut releases, and guide collaboration with the community. They have: + +* Commit access to their project's repositories +* Write access to continuous integration (CI) jobs + +Both maintainers and non-maintainers may propose changes to +source code. The mechanism to propose such a change is a GitHub pull request. Maintainers review and merge (_land_) pull requests. + +If a maintainer opposes a proposed change, then the change cannot land. The exception is if the Governance Committee (GC) votes to approve the change despite the opposition. Usually, involving the GC is unnecessary. + +See: + +* [Code Owners - `CODEOWNERS`](./.github/CODEOWNERS) +* [Contribution Guide - `CONTRIBUTING.md`](./CONTRIBUTING.md) + +### Maintainer activities + +* Helping users and novice contributors +* Contributing code and documentation changes that improve the project +* Reviewing and commenting on issues and pull requests +* Participation in working groups +* Merging pull requests + +## Governance Committee + +The Block Open Source Governance Committee (GC) has final authority over this project, including: + +* Technical direction +* Project governance and process (including this policy) +* Contribution policy +* GitHub repository hosting +* Conduct guidelines +* Maintaining the list of maintainers + +The GC may be reached through `open-source-governance@block.xyz` and is an available resource in mediation or for sensitive cases beyond the scope of project maintainers. It operates as a "Self-appointing council or board" as defined by Red Hat: [Open Source Governance Models](https://www.redhat.com/en/blog/understanding-open-source-governance-models). diff --git a/LICENSE b/LICENSE index 862ee3c28..101bf10d0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -48,7 +49,7 @@ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner + submitted to the Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent @@ -60,7 +61,7 @@ designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and + on behalf of whom a Contribution has been received by the Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of @@ -106,7 +107,7 @@ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not + within such NOTICE file, excluding any notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or @@ -175,16 +176,7 @@ END OF TERMS AND CONDITIONS - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. + Copyright 2024 Block, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -192,8 +184,6 @@ http://www.apache.org/licenses/LICENSE-2.0 -Copyright 2026 Block, Inc. - Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..9ef84c520 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +server: cd server && just dev +protoFleet: cd client && npm run dev:protoFleet \ No newline at end of file diff --git a/README.md b/README.md index 0905dcff1..c4404ce4d 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,117 @@ -# proto-fleet README +

+ + Proto logo + +

+

+ Proto Fleet +

+

+ Mining management software. Evolved. +

+

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

+

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

-Congrats, project leads! You got a new project to grow! +**Proto Fleet** is open-source fleet management software for bitcoin miners. It helps operators pair devices, monitor telemetry, and manage mining infrastructure without giving up control. Built with React and TypeScript clients, Go services, Connect RPC, Protocol Buffers, and TimescaleDB. For architecture details, see [docs/architecture.md](docs/architecture.md). -This stub is meant to help you form a strong community around your work. It's yours to adapt, and may -diverge from this initial structure. Just keep the files seeded in this repo, and the rest is yours to evolve! +## Getting Started -## Introduction +### Prerequisites -Orient users to the project here. This is a good place to start with an assumption -that the user knows very little - so start with the Big Picture and show how this -project fits into it. +- Docker and Docker Compose +- [Hermit](https://cashapp.github.io/hermit/), or a local installation of the required development tools -Then maybe a dive into what this project does. +### Initial Setup -Diagrams and other visuals are helpful here. Perhaps code snippets showing usage. +```bash +source ./bin/activate-hermit +just setup +``` -Project leads should complete, alongside this `README`: +To install Git hooks after your toolchain is ready: -* [CODEOWNERS](./CODEOWNERS) - set project lead(s) -* [CONTRIBUTING.md](./CONTRIBUTING.md) - Fill out how to: install prereqs, build, test, run, access CI, chat, discuss, file issues -* [Bug-report.md](.github/ISSUE_TEMPLATE/bug-report.md) - Fill out `Assignees` add codeowners @names -* [config.yml](.github/ISSUE_TEMPLATE/config.yml) - remove "(/add your discord channel..)" and replace the url with your Discord channel if applicable +```bash +just install-hooks +``` -The other files in this template repo may be used as-is: +For non-Hermit setup details, `lefthook` and Ruff hook prerequisites, and `go.work` guidance, see [CONTRIBUTING.md](CONTRIBUTING.md). -* [GOVERNANCE.md](./GOVERNANCE.md) -* [LICENSE](./LICENSE) +### Start Development -## Project Resources +```bash +just dev +``` -| Resource | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------ | -| [CODEOWNERS](./CODEOWNERS) | Outlines the project lead(s) | -| [GOVERNANCE.md](./GOVERNANCE.md) | Project governance | -| [LICENSE](./LICENSE) | Apache License, Version 2.0 | +This starts the Go backend with Docker Compose and the Vite dev server for ProtoFleet at http://localhost:5173. + +### Protocol Buffer Code Generation + +After modifying definitions in `proto/`, regenerate generated clients and server code: + +```bash +just gen +``` + +## Supported Hardware + +| Hardware | Firmware variants | Discovery port | +| --- | --- | --- | +| MicroBT WhatsMiner | Stock | 4028 | +| Bitmain Antminer | Stock | 4028 | +| Bitmain Antminer | VNish, Braiins OS, LuxOS, Marathon | 80 | +| Canaan AvalonMiner | Stock | 4028 | +| BitAxe | Stock (AxeOS) | 80 | +| NerdAxe | Stock | 80 | +| ePIC | Stock | 80 | +| Auradine | Stock | 80 | +| Proto | Stock | 443 | + +## Production Install + +### Latest Version + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/install.sh) +``` + +### Specific Version + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/install.sh) v0.1.0 +``` + +### Uninstall + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/uninstall.sh) +``` + +If Proto Fleet was installed in a non-default location, pass it explicitly: + +```bash +bash <(curl -fsSL https://fleet.proto.xyz/uninstall.sh) --deployment-path /path/to/install/root +``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflows and contribution guidelines. Project standards and community expectations are documented in [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), [GOVERNANCE.md](GOVERNANCE.md), and [SECURITY.md](SECURITY.md). + +## License + +This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c2c5722ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security Policy + +Block recognizes the important contributions our open source community makes. We welcome +contributions, including any bug fixes or vulnerabilities that you find. We encourage you to privately +report it in the repository's `Security` tab -> `Report a vulnerability`. + +Please see [Privately reporting a security vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) for more information. + +## Scope + +This policy applies to the [Proto Fleet](https://github.com/proto-at-block/proto-fleet) repository. + +## Disclosure Procedures + +We do not publicly disclose vulnerabilities by default. We take the security of our services very seriously and +monitor their use for indications of a malicious attack. In order to distinguish legitimate security research +from malicious attacks against our services, we promise not to bring legal action against researchers who: + +* Share with us the full details of any problem found. +* Do not disclose the issue to others until we've had a reasonable time to address it and disclosure has been approved by us. +* Do not intentionally harm the experience or usefulness of the service to others. +* Never attempt to view, modify, access, disclose, exfiltrate, use or damage data belonging to Block, its customers, or others. +* Do not attempt a denial-of-service attack. +* Do not perform any research or testing in violation of the law. + +## Security Contacts + +For assistance or escalation, please contact the Block Open Source Governance Committee: `open-source-governance@block.xyz` diff --git a/bin/.buf-1.57.2.pkg b/bin/.buf-1.57.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.buf-1.57.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.gh-2.53.0.pkg b/bin/.gh-2.53.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.gh-2.53.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.git-absorb-0.7.0.pkg b/bin/.git-absorb-0.7.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.git-absorb-0.7.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.go-1.25.4.pkg b/bin/.go-1.25.4.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.go-1.25.4.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.goimports-0.3.0.pkg b/bin/.goimports-0.3.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.goimports-0.3.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.golangci-lint-2.6.2.pkg b/bin/.golangci-lint-2.6.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.golangci-lint-2.6.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.grpcurl-1.9.2.pkg b/bin/.grpcurl-1.9.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.grpcurl-1.9.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.jq-1.7.1.pkg b/bin/.jq-1.7.1.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.jq-1.7.1.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.just-1.40.0.pkg b/bin/.just-1.40.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.just-1.40.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.lefthook-2.1.4.pkg b/bin/.lefthook-2.1.4.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.lefthook-2.1.4.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.migrate-4.18.2.pkg b/bin/.migrate-4.18.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.migrate-4.18.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.mockgen-1.6.0.pkg b/bin/.mockgen-1.6.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.mockgen-1.6.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.mysql-client-8.0.36.pkg b/bin/.mysql-client-8.0.36.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.mysql-client-8.0.36.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.nfpm-2.45.0.pkg b/bin/.nfpm-2.45.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.nfpm-2.45.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.node-22.14.0.pkg b/bin/.node-22.14.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.node-22.14.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.proto-python-gen-0.2.0.pkg b/bin/.proto-python-gen-0.2.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.proto-python-gen-0.2.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-30.2.pkg b/bin/.protoc-30.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-30.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-gen-connect-go-1.12.0.pkg b/bin/.protoc-gen-connect-go-1.12.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-gen-connect-go-1.12.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-gen-go-1.36.5.pkg b/bin/.protoc-gen-go-1.36.5.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-gen-go-1.36.5.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-gen-go-grpc-1.3.0.pkg b/bin/.protoc-gen-go-grpc-1.3.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.protoc-gen-go-grpc-1.3.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.python3-3.13.2.pkg b/bin/.python3-3.13.2.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.python3-3.13.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.sqlc-1.28.0.pkg b/bin/.sqlc-1.28.0.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.sqlc-1.28.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md new file mode 100644 index 000000000..e889550ba --- /dev/null +++ b/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit new file mode 100755 index 000000000..fe28214d3 --- /dev/null +++ b/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/bin/activate-hermit.fish b/bin/activate-hermit.fish new file mode 100755 index 000000000..0367d2331 --- /dev/null +++ b/bin/activate-hermit.fish @@ -0,0 +1,24 @@ +#!/usr/bin/env fish + +# This file must be sourced with "source bin/activate-hermit.fish" from Fish shell. +# You cannot run it directly. +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if status is-interactive + set BIN_DIR (dirname (status --current-filename)) + + if "$BIN_DIR/hermit" noop > /dev/null + # Source the activation script generated by Hermit + "$BIN_DIR/hermit" activate "$BIN_DIR/.." | source + + # Clear the command cache if applicable + functions -c > /dev/null 2>&1 + + # Display activation message + echo "Hermit environment $($HERMIT_ENV/bin/hermit env HERMIT_ENV) activated" + end +else + echo "You must source this script: source $argv[0]" >&2 + exit 33 +end diff --git a/bin/buf b/bin/buf new file mode 120000 index 000000000..90c928b77 --- /dev/null +++ b/bin/buf @@ -0,0 +1 @@ +.buf-1.57.2.pkg \ No newline at end of file diff --git a/bin/corepack b/bin/corepack new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/corepack @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/gh b/bin/gh new file mode 120000 index 000000000..80f8aafd6 --- /dev/null +++ b/bin/gh @@ -0,0 +1 @@ +.gh-2.53.0.pkg \ No newline at end of file diff --git a/bin/git-absorb b/bin/git-absorb new file mode 120000 index 000000000..f1aa2ac35 --- /dev/null +++ b/bin/git-absorb @@ -0,0 +1 @@ +.git-absorb-0.7.0.pkg \ No newline at end of file diff --git a/bin/go b/bin/go new file mode 120000 index 000000000..e6546f4a2 --- /dev/null +++ b/bin/go @@ -0,0 +1 @@ +.go-1.25.4.pkg \ No newline at end of file diff --git a/bin/gofmt b/bin/gofmt new file mode 120000 index 000000000..e6546f4a2 --- /dev/null +++ b/bin/gofmt @@ -0,0 +1 @@ +.go-1.25.4.pkg \ No newline at end of file diff --git a/bin/goimports b/bin/goimports new file mode 120000 index 000000000..4aa0e9785 --- /dev/null +++ b/bin/goimports @@ -0,0 +1 @@ +.goimports-0.3.0.pkg \ No newline at end of file diff --git a/bin/golangci-lint b/bin/golangci-lint new file mode 120000 index 000000000..16bfafb67 --- /dev/null +++ b/bin/golangci-lint @@ -0,0 +1 @@ +.golangci-lint-2.6.2.pkg \ No newline at end of file diff --git a/bin/grpcurl b/bin/grpcurl new file mode 120000 index 000000000..38dcbba5b --- /dev/null +++ b/bin/grpcurl @@ -0,0 +1 @@ +.grpcurl-1.9.2.pkg \ No newline at end of file diff --git a/bin/hermit b/bin/hermit new file mode 100755 index 000000000..7fef76924 --- /dev/null +++ b/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="180e997dd837f839a3072a5e2f558619b6d12555cd5452d3ab19d87720704e38" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl new file mode 100644 index 000000000..5c05d9f0e --- /dev/null +++ b/bin/hermit.hcl @@ -0,0 +1,8 @@ +manage-git = false +sources = [ + "https://github.com/cashapp/hermit-packages.git", + "env:///hermit-packages", +] + +github-token-auth { +} diff --git a/bin/jq b/bin/jq new file mode 120000 index 000000000..f4ed68d73 --- /dev/null +++ b/bin/jq @@ -0,0 +1 @@ +.jq-1.7.1.pkg \ No newline at end of file diff --git a/bin/just b/bin/just new file mode 120000 index 000000000..63271c139 --- /dev/null +++ b/bin/just @@ -0,0 +1 @@ +.just-1.40.0.pkg \ No newline at end of file diff --git a/bin/lefthook b/bin/lefthook new file mode 120000 index 000000000..6e6dff205 --- /dev/null +++ b/bin/lefthook @@ -0,0 +1 @@ +.lefthook-2.1.4.pkg \ No newline at end of file diff --git a/bin/migrate b/bin/migrate new file mode 120000 index 000000000..c551b2e6c --- /dev/null +++ b/bin/migrate @@ -0,0 +1 @@ +.migrate-4.18.2.pkg \ No newline at end of file diff --git a/bin/mockgen b/bin/mockgen new file mode 120000 index 000000000..6e7806a1f --- /dev/null +++ b/bin/mockgen @@ -0,0 +1 @@ +.mockgen-1.6.0.pkg \ No newline at end of file diff --git a/bin/mysql b/bin/mysql new file mode 120000 index 000000000..42bffb0fd --- /dev/null +++ b/bin/mysql @@ -0,0 +1 @@ +.mysql-client-8.0.36.pkg \ No newline at end of file diff --git a/bin/nfpm b/bin/nfpm new file mode 120000 index 000000000..e78e01a0f --- /dev/null +++ b/bin/nfpm @@ -0,0 +1 @@ +.nfpm-2.45.0.pkg \ No newline at end of file diff --git a/bin/node b/bin/node new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/node @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/npm b/bin/npm new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/npm @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/npx b/bin/npx new file mode 120000 index 000000000..8084f9c4c --- /dev/null +++ b/bin/npx @@ -0,0 +1 @@ +.node-22.14.0.pkg \ No newline at end of file diff --git a/bin/pip b/bin/pip new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pip @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/pip3 b/bin/pip3 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pip3 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/pip3.13 b/bin/pip3.13 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pip3.13 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/protoc b/bin/protoc new file mode 120000 index 000000000..07d33ca13 --- /dev/null +++ b/bin/protoc @@ -0,0 +1 @@ +.protoc-30.2.pkg \ No newline at end of file diff --git a/bin/protoc-gen-buf-breaking b/bin/protoc-gen-buf-breaking new file mode 120000 index 000000000..90c928b77 --- /dev/null +++ b/bin/protoc-gen-buf-breaking @@ -0,0 +1 @@ +.buf-1.57.2.pkg \ No newline at end of file diff --git a/bin/protoc-gen-buf-lint b/bin/protoc-gen-buf-lint new file mode 120000 index 000000000..90c928b77 --- /dev/null +++ b/bin/protoc-gen-buf-lint @@ -0,0 +1 @@ +.buf-1.57.2.pkg \ No newline at end of file diff --git a/bin/protoc-gen-connect-go b/bin/protoc-gen-connect-go new file mode 120000 index 000000000..d58574cb0 --- /dev/null +++ b/bin/protoc-gen-connect-go @@ -0,0 +1 @@ +.protoc-gen-connect-go-1.12.0.pkg \ No newline at end of file diff --git a/bin/protoc-gen-go b/bin/protoc-gen-go new file mode 120000 index 000000000..84bd23989 --- /dev/null +++ b/bin/protoc-gen-go @@ -0,0 +1 @@ +.protoc-gen-go-1.36.5.pkg \ No newline at end of file diff --git a/bin/protoc-gen-go-grpc b/bin/protoc-gen-go-grpc new file mode 120000 index 000000000..af41d7e30 --- /dev/null +++ b/bin/protoc-gen-go-grpc @@ -0,0 +1 @@ +.protoc-gen-go-grpc-1.3.0.pkg \ No newline at end of file diff --git a/bin/protoc-gen-python-grpc b/bin/protoc-gen-python-grpc new file mode 120000 index 000000000..613ae5272 --- /dev/null +++ b/bin/protoc-gen-python-grpc @@ -0,0 +1 @@ +.proto-python-gen-0.2.0.pkg \ No newline at end of file diff --git a/bin/pydoc3 b/bin/pydoc3 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pydoc3 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/pydoc3.13 b/bin/pydoc3.13 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/pydoc3.13 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python b/bin/python new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3 b/bin/python3 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3-config b/bin/python3-config new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3-config @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3.13 b/bin/python3.13 new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3.13 @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/python3.13-config b/bin/python3.13-config new file mode 120000 index 000000000..ce2a6be54 --- /dev/null +++ b/bin/python3.13-config @@ -0,0 +1 @@ +.python3-3.13.2.pkg \ No newline at end of file diff --git a/bin/sqlc b/bin/sqlc new file mode 120000 index 000000000..1dcdccd62 --- /dev/null +++ b/bin/sqlc @@ -0,0 +1 @@ +.sqlc-1.28.0.pkg \ No newline at end of file diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 000000000..8ad47cb21 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,23 @@ +version: v2 +plugins: + - local: protoc-gen-go + out: server/generated/grpc + opt: paths=source_relative + include_imports: true + - local: protoc-gen-connect-go + out: server/generated/grpc + opt: paths=source_relative + - local: protoc-gen-es + out: client/src/protoFleet/api/generated + include_imports: true + opt: target=ts +managed: + enabled: true + disable: + # Don't modify any files in buf.build/googleapis/googleapis + - module: buf.build/googleapis/googleapis + # Don't modify any files in buf.build/bufbuild/protovalidate + - module: buf.build/bufbuild/protovalidate + override: + - file_option: go_package_prefix + value: github.com/block/proto-fleet/server/generated/grpc diff --git a/buf.lock b/buf.lock new file mode 100644 index 000000000..cadea4df7 --- /dev/null +++ b/buf.lock @@ -0,0 +1,9 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/bufbuild/protovalidate + commit: 8976f5be98c146529b1cc15cd2012b60 + digest: b5:5d513af91a439d9e78cacac0c9455c7cb885a8737d30405d0b91974fe05276d19c07a876a51a107213a3d01b83ecc912996cdad4cddf7231f91379079cf7488d + - name: buf.build/googleapis/googleapis + commit: 61b203b9a9164be9a834f58c37be6f62 + digest: b5:7811a98b35bd2e4ae5c3ac73c8b3d9ae429f3a790da15de188dc98fc2b77d6bb10e45711f14903af9553fa9821dff256054f2e4b7795789265bc476bec2f088c diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 000000000..159686615 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,13 @@ +# For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml +version: v2 +lint: + use: + - STANDARD +breaking: + use: + - FILE +modules: + - path: proto +deps: + - buf.build/googleapis/googleapis + - buf.build/bufbuild/protovalidate diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 000000000..3c3629e64 --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 000000000..a312fe4ed --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist-ssr +*.local +.env + +# build directories +dist + +# temporary directories from build +src/protoOS/public +src/protoFleet/public + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Playwright test artifacts +test-results +playwright-report diff --git a/client/.npmrc b/client/.npmrc new file mode 100644 index 000000000..38f11c645 --- /dev/null +++ b/client/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/client/.prettierignore b/client/.prettierignore new file mode 100644 index 000000000..7205d83d4 --- /dev/null +++ b/client/.prettierignore @@ -0,0 +1,16 @@ +# Ignore artifacts: +build +coverage +dist + +# Ignore all HTML files: +*.html + +# Ignore node_modules +node_modules + +# Ignore pnpm lock file +pnpm-lock.yaml + +# Ignore CLAUDE.md documentation +CLAUDE.md \ No newline at end of file diff --git a/client/.prettierrc.js b/client/.prettierrc.js new file mode 100644 index 000000000..0b9132a4d --- /dev/null +++ b/client/.prettierrc.js @@ -0,0 +1,15 @@ +export default { + semi: true, + useTabs: false, + trailingComma: "all", + singleQuote: false, + printWidth: 120, + tabWidth: 2, + bracketSpacing: true, + bracketSameLine: false, + endOfLine: "lf", + arrowParens: "always", + jsxSingleQuote: false, + plugins: ["prettier-plugin-tailwindcss"], + tailwindStylesheet: "./src/shared/styles/index.css", +}; diff --git a/client/.storybook/main.ts b/client/.storybook/main.ts new file mode 100644 index 000000000..12a6645a9 --- /dev/null +++ b/client/.storybook/main.ts @@ -0,0 +1,9 @@ +const config = { + stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: ["@storybook/addon-actions", "@storybook/addon-docs"], + framework: { + name: "@storybook/react-vite", + options: {}, + }, +}; +export default config; diff --git a/client/.storybook/preview-body.html b/client/.storybook/preview-body.html new file mode 100644 index 000000000..c9c6b44aa --- /dev/null +++ b/client/.storybook/preview-body.html @@ -0,0 +1,13 @@ + + + + + + diff --git a/client/.storybook/preview.tsx b/client/.storybook/preview.tsx new file mode 100644 index 000000000..c1fc2e538 --- /dev/null +++ b/client/.storybook/preview.tsx @@ -0,0 +1,82 @@ +/* eslint-disable react-refresh/only-export-components */ +import React, { ComponentType, useEffect, useMemo } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import type { Preview } from "@storybook/react-vite"; +import "../src/shared/styles/index.css"; + +import { spyOn } from "storybook/test"; + +export const beforeEach = () => { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); +}; + +const ThemeWrapper = ({ theme, children }: { theme: string; children: React.ReactNode }) => { + useEffect(() => { + document.body.setAttribute("data-theme", theme); + }, [theme]); + return <>{children}; +}; + +const StoryRouter = ({ Story }: { Story: ComponentType }) => { + const router = useMemo(() => createMemoryRouter([{ path: "*", element: }]), [Story]); + + return ; +}; + +export const decorators = [ + (Story: ComponentType, context: { globals: { theme?: string }; parameters: { withRouter?: boolean } }) => { + const theme = context.globals.theme || "light"; + + if (context.parameters.withRouter === false) { + return ( + + + + ); + } + + return ( + + + + ); + }, +]; + +const preview: Preview = { + globalTypes: { + theme: { + description: "Theme", + toolbar: { + title: "Theme", + icon: "mirror", + items: [ + { value: "light", title: "Light", icon: "sun" }, + { value: "dark", title: "Dark", icon: "moon" }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + theme: "light", + }, + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + layout: "fullscreen", + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + options: { + storySort: { + order: ["Foundation", "Shared", "ProtoOS", "Proto Fleet", "*"], + }, + }, + }, +}; + +export default preview; diff --git a/client/README.md b/client/README.md new file mode 100644 index 000000000..3a806989b --- /dev/null +++ b/client/README.md @@ -0,0 +1,218 @@ +# Client + +## Overview + +This directory contains two React applications and a shared component library: + +- **ProtoOS**: Mining dashboard UI served by the miner's embedded API server (single-miner view) +- **ProtoFleet**: Fleet management UI for managing multiple miners (fleet-wide view) +- **Shared**: Common UI components used by both applications + +### Tech Stack + +- **React 19** with TypeScript +- **Vite 7** for builds and dev server +- **Zustand** for state management with Immer middleware +- **React Router 7** for routing +- **Tailwind CSS 4** for styling +- **Vitest** and Testing Library for testing +- **Storybook 10** for component documentation +- **Recharts** for data visualization +- **Motion** (Framer Motion) for animations + +## Directory Layout + +``` +client +├── .storybook # Storybook configuration +├── dist # Compiled production output +│ ├── protoFleet # ProtoFleet build output +│ └── protoOS # ProtoOS build output +├── public # Favicon and static assets +├── scripts # Development scripts +├── src +│ ├── protoFleet # Fleet management UI source +│ │ └── index.html # ProtoFleet entry point +│ ├── protoOS # Mining dashboard UI source +│ │ └── index.html # ProtoOS entry point +│ └── shared # Shared components, hooks, and utilities +├── eslint.config.js # Linting rules +├── package.json # Dependencies and npm scripts +├── postcss.config.js # PostCSS/Tailwind configuration +├── tsconfig.json # TypeScript configuration +└── vite.config.ts # Vite multi-app build configuration +``` + +## Getting Started + +### Install dependencies + +```bash +npm install +``` + +### Start dev server + +```bash +# Start ProtoOS dev server +npm run dev:protoOS + +# Start ProtoFleet dev server +npm run dev:protoFleet + +# Access at http://localhost:5173 +``` + +### Proxy Setup + +Both apps require proxy configuration to route API requests to backend servers. Create a `.env` file in this directory: + +**ProtoOS**: + +``` +PROXY_URL=http://127.0.0.1:8000 +``` + +Routes `/api/v1` requests to the miner API server. The proxy URL can point to a locally running miner-api-server, a test node IP, or a mock data API server like [Stoplight](https://stoplight.io/mocks/proto-mining/mdk-api/656299768). + +**ProtoFleet**: + +``` +FLEET_PROXY_URL=http://127.0.0.1:4000 +``` + +Routes `/api-proxy` requests to the fleet server. If you are implementing a new API endpoint, you may need to add the path to `vite.config.ts`. + +## Building + +```bash +# Build both applications +npm run build + +# Build individual applications +npm run build:protoOS +npm run build:protoFleet + +# Preview production builds +npm run preview:protoOS +npm run preview:protoFleet +``` + +### Multi-App Build System + +Vite is configured with mode-based builds. Each app has its own `index.html` entry point in `src/{app}/` and builds to `dist/{app}/`. Always specify the mode when building: `vite build --mode protoOS`. + +## Testing + +```bash +# Run all tests +npm test + +# Run tests matching a pattern +npx vitest run + +# Watch mode for a specific file +npx vitest watch + +# Run tests in a specific directory +npx vitest run src/protoOS/features/kpis +``` + +## Code Quality + +```bash +# Lint code +npm run lint + +# Format code with Prettier +npm run format + +# Check formatting without writing +npm run format:check + +# Run Storybook for visual component testing +npm run storybook +``` + +## Architecture + +### State Management + +**ProtoOS** uses Zustand with a slice-based architecture (`useMinerStore`): + +- Hardware, Telemetry, UI, Auth, Miner Status, Mining Target, Network Info, System Info slices +- Key data types: `Measurement`, `MetricTelemetry`, `MetricTimeSeries` +- See `src/protoOS/store/README.md` for comprehensive documentation + +**ProtoFleet** uses Zustand with a slice-based architecture (`useFleetStore`): + +- Fleet, UI, Auth, Onboarding slices +- Fleet slice handles miner collection, device status counts, filtering, and streaming telemetry + +### API Integration + +**ProtoOS** — REST API with generated TypeScript client from `proto-rig-api/openapi/MDK-API.json`. Application code uses hooks in `src/protoOS/api/hooks/` which handle error handling, polling, and automatic store updates. Regenerate types with `npm run generate-api-types`. + +**ProtoFleet** — gRPC-Web with Connect-RPC. Generated TypeScript code in `src/protoFleet/api/generated/` from Protobuf definitions. Supports server-to-client streaming for real-time telemetry. Custom hooks in `src/protoFleet/api/`. + +### Import Rules + +Use the `@/` path alias for all absolute imports: + +```typescript +// Good +import { Button } from "@/shared/components/Button"; +import { useMinerStore } from "@/protoOS/store"; + +// Bad +import { Button } from "../../../shared/components/Button"; +``` + +Strict import boundaries: + +- `src/shared/` must never import from `src/protoOS` or `src/protoFleet` +- `src/protoOS` must never import from `src/protoFleet`, and vice versa + +### Component Organization + +Components follow a feature-based structure: + +``` +features/ +└── kpis/ + ├── components/ # Feature-specific components + ├── utils/ # Feature utilities + ├── types.ts # Feature types + └── index.ts # Public exports +``` + +- Components used within a single feature live in that feature's `components/` directory +- Components shared across features within one app live in `src/{app}/components/` +- Components shared across both apps live in `src/shared/components/` +- Shared components should be pure — consistent output given the same props + +### Shared Components + +Reusable components in `src/shared/components/` include: + +- **Layout**: Card, ContentHeader, Divider, BackgroundImage +- **Interactive**: Button, ButtonGroup, Dialog, Modal, DurationSelector, Toggle +- **Data Display**: Chart, DataNullState, Callout, Chip, StatusBadge +- **Forms**: Checkbox, Input, Select, TextArea +- **Feedback**: Spinner, ErrorBoundary, Toast + +All shared components have Storybook stories, support light/dark themes, and include TypeScript prop types. + +## Testing on Hardware + +1. Compile the UI: `npm run build` +2. Build the Linux image via GitHub Actions +3. Transfer the image to the control board's SD card +4. Connect the board via ethernet and access the UI at the board's IP address + +## Learn More + +- [React](https://react.dev/learn) +- [Vite](https://vitejs.dev/guide/) +- [Tailwind CSS](https://tailwindcss.com/docs/utility-first) +- [Recharts](https://release--63da8268a0da9970db6992aa.chromatic.com/?path=/docs/welcome--docs) diff --git a/client/e2eTests/protoFleet/.gitignore b/client/e2eTests/protoFleet/.gitignore new file mode 100644 index 000000000..3bae91a8f --- /dev/null +++ b/client/e2eTests/protoFleet/.gitignore @@ -0,0 +1,28 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ + +# Environment variables +.env +.env.local +.env*.local + +# Local test config (not committed) +config/test.config.local.ts + +# macOS +.DS_Store + +# Editor +.vscode/ +.idea/ + +# Lock files (optional - remove if you want to commit them) +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/client/e2eTests/protoFleet/README.md b/client/e2eTests/protoFleet/README.md new file mode 100644 index 000000000..bbe0a34ad --- /dev/null +++ b/client/e2eTests/protoFleet/README.md @@ -0,0 +1,374 @@ +# End-to-End Tests + +This directory contains end-to-end (E2E) tests for the ProtoFleet client application using Playwright. + +## Overview + +The E2E test suite validates critical user workflows and functionality across the ProtoFleet application, including authentication, miner management, pool configuration, and settings management. + +## Getting Started + +### Prerequisites + +- All dependencies installed via `npm install` in the client directory +- ProtoFleet development environment and fake miners set up (usually with `just dev`) + +### Quick Start + +The test configuration is already set up with default values. Simply run: + +```bash +just test-e2e-fleet +``` + +This command will: + +- Install Playwright browsers automatically if needed +- Run all tests in desktop mode +- Generate an HTML report + +🔒 The default credentials are `admin` and `Pass123!` + +### Available Commands + +**Using justfile (recommended):** + +```bash +just test-e2e-fleet # Run all tests (desktop) +just test-e2e-fleet-ui # Run in interactive UI mode +just test-e2e-fleet-headed # Run with visible browser +just test-e2e-fleet-wip # Run only tests tagged @wip +``` + +**Using npm scripts:** + +```bash +npm run test:e2e # Run all tests (desktop) +npm run test:e2e:ui # Run in interactive UI mode +npm run test:e2e:headed # Run with visible browser +``` + +### Test Execution Strategy + +- **Pull Requests**: Run the full Fleet suite through parallel spec workers +- **Nightly Builds**: Run the full Fleet suite +- **Manual Runs**: Run the full Fleet suite by default + +In CI, spec files whose names start with two digits, such as `00-onboarding.spec.ts` and `01-miningPools.spec.ts`, are treated as setup specs. They are replayed first, in filename order, before each non-setup spec worker runs its assigned spec file. + +**Using Playwright directly:** + +```bash +cd e2eTests +npx playwright test --project=desktop # Desktop viewport (1920x1080) +npx playwright test --project=mobile # Mobile viewport (393x852) +npx playwright test --headed # See browser +npx playwright test --debug # Debug mode +npx playwright test --ui # Interactive UI +npx playwright test spec/auth.spec.ts # Run specific file +``` + +### Viewing Test Reports + +After running tests, view the HTML report: + +```bash +npx playwright show-report +``` + +The report includes: + +- Test results and execution times +- Screenshots (captured on failure) +- Videos (retained on failure) +- Traces (captured on first retry) + +### Configuration + +Test configuration is in `config/test.config.ts`: + +```typescript +export const testConfig = { + baseUrl: "http://localhost:5173", + users: { + admin: { + username: "admin", + password: "Pass123!", + }, + }, + testTimeout: 60000, + actionTimeout: 30000, +}; +``` + +### Desktop vs Mobile Testing + +The test suite supports both desktop and mobile viewports, configured in `playwright.config.ts`: + +- **Desktop**: 1920x1080 viewport (default) +- **Mobile**: 393x852 viewport (iPhone 14/15/16 Pro resolution) + +Switch between projects using the `--project` flag: + +```bash +npx playwright test --project=desktop +npx playwright test --project=mobile +``` + +## Tech Stack + +- **[Playwright](https://playwright.dev/)**: Modern end-to-end testing framework +- **TypeScript**: Type-safe test development +- **Page Object Model**: Organized, maintainable test structure + +## Project Structure + +``` +e2eTests/ +├── config/ # Test configuration files +│ └── test.config.ts # Base URL, user credentials, timeouts +├── fixtures/ # Playwright fixtures for dependency injection +│ └── pageFixtures.ts # Page object and helper fixtures +├── helpers/ # Reusable test helper classes +│ ├── commonSteps.ts # Common test workflows (login, navigation) +│ └── testDataHelper.ts # Test data generation utilities +├── pages/ # Page Object Model implementations +│ ├── base.ts # Base page class with common methods +│ ├── auth.ts # Authentication page objects +│ ├── home.ts # Home page objects +│ ├── miners.ts # Miners page objects +│ ├── addMiners.ts # Add miners page objects +│ ├── editPool.ts # Pool editor page objects +│ ├── newPoolModal.ts # New pool modal objects +│ ├── settings.ts # Settings page objects +│ ├── settingsSecurity.ts # Security settings page objects +│ ├── settingsTeam.ts # Team settings page objects +│ └── settingsPools.ts # Pool settings page objects +├── spec/ # Test specifications +│ ├── 00-onboarding.spec.ts # Initial setup and onboarding tests +│ ├── 01-miningPools.spec.ts # Pool configuration tests +│ ├── auth.spec.ts # Authentication tests +│ ├── minersActions.spec.ts # Miner management and actions tests +│ ├── securitySettings.spec.ts # Security settings tests +│ ├── generalSettings.spec.ts # General settings tests +│ ├── teamAccounts.spec.ts # Team account management tests +│ └── navigation.spec.ts # Navigation flow tests +├── playwright-report/ # Generated test reports (gitignored) +└── playwright.config.ts # Playwright configuration +``` + +## Writing Tests + +### Page Object Pattern + +Tests use the Page Object Model pattern to encapsulate page interactions: + +```typescript +// Example: pages/miners.ts +export class MinersPage extends BasePage { + async clickSelectAllCheckbox() { + await this.page.locator('[data-testid="select-all-checkbox"]').click(); + } + + async validateAmountOfMiners(expected: number) { + const miners = this.page.locator('[data-testid="miner-row"]'); + await expect(miners).toHaveCount(expected); + } +} +``` + +### Helper Classes + +Common test workflows are encapsulated in helper classes: + +```typescript +// Example: helpers/commonSteps.ts +export class CommonSteps { + async loginAsAdmin() { + await test.step("Login as admin", async () => { + await this.authPage.inputUsername(testConfig.users.admin.username); + await this.authPage.inputPassword(testConfig.users.admin.password); + await this.authPage.clickLogin(); + await this.authPage.validateLoggedIn(); + }); + } + + async goToMinersPage() { + await test.step("Navigate to miners page", async () => { + await this.minersPage.navigateToMinersPage(); + await this.minersPage.waitForMinersTitle(); + await this.minersPage.waitForMinersListToLoad(); + }); + } +} +``` + +### Using Fixtures + +Fixtures provide automatic dependency injection for page objects and helpers: + +```typescript +import { test } from "../fixtures/pageFixtures"; + +test("My test", async ({ authPage, minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await minersPage.validateMiners(); +}); +``` + +Available fixtures: + +- `authPage` - Authentication page +- `homePage` - Home page +- `minersPage` - Miners page +- `addMinersPage` - Add miners page +- `settingsPage` - Settings page +- `settingsSecurityPage` - Security settings page +- `settingsTeamPage` - Team settings page +- `settingsPoolsPage` - Pool settings page +- `editPoolPage` - Pool editor page +- `newPoolModal` - New pool modal +- `commonSteps` - Common test workflows + +### Test Structure Example + +```typescript +test.describe("Feature Name", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("should perform action", async ({ minersPage, commonSteps }) => { + // Arrange + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + // Act + await test.step("Perform action", async () => { + await minersPage.performAction(); + }); + + // Assert + await test.step("Validate result", async () => { + await minersPage.validateResult(); + }); + }); +}); +``` + +## Best Practices + +### Test Organization + +- Group related tests using `test.describe()` +- Use descriptive test names that explain the scenario +- Keep tests independent and idempotent +- Use `beforeEach` for common setup +- Use `test.step()` to organize test logic into readable sections +- Leverage `commonSteps` helper for frequently used workflows +- Number test files (00-, 01-) when execution order matters + +### Locator Strategy + +1. **Prefer data-testid attributes**: `page.locator('[data-testid="button-name"]')` +2. **Use semantic selectors**: `page.getByRole('button', { name: 'Submit' })` +3. **Avoid brittle selectors**: Don't rely on class names or DOM structure + +### Assertions + +- Use Playwright's built-in assertions with auto-waiting +- Validate expected states explicitly +- Include meaningful assertion messages when needed + +```typescript +await expect(element).toBeVisible(); +await expect(element).toHaveText("Expected text"); +await expect(page).toHaveURL(/.*\/expected-path/); +``` + +### API Validation + +- Use `page.waitForRequest()` and `page.waitForResponse()` to validate API calls +- Verify request payloads and response status codes +- Ensure critical operations complete successfully at the network level + +```typescript +const responsePromise = page.waitForResponse( + (response) => response.url().includes("/api/reboot") && response.status() === 200, +); +await minersPage.clickRebootButton(); +await responsePromise; +``` + +### Error Handling + +- Tests automatically capture screenshots and videos on failure +- Use traces for debugging complex failures +- Add explicit waits for dynamic content +- Validate both UI state and API responses for critical operations + +### Code Quality + +- Disable `playwright/expect-expect` ESLint rule only when page objects handle assertions +- Keep page objects focused on single pages or components +- Reuse common functionality in `BasePage` + +## Troubleshooting + +### Tests fail to connect to application + +- Ensure the client is running on `http://localhost:5173` +- Check that the server backend is running and accessible +- Verify virtual miners are running (if testing miner functionality) + +### Timeouts + +- Increase timeout in `config/test.config.ts` +- Check for slow network or server responses +- Verify selectors are correct and elements are rendered + +### Browser issues + +- Reinstall browsers: `npx playwright install --force` +- Check Playwright version compatibility +- Clear browser state between test runs + +## CI/CD Integration + +E2E tests run automatically in GitHub Actions: + +- **Triggers**: Pull requests affecting e2e tests, daily at 7 AM UTC, manual dispatch +- **Matrix**: Tests run on both `desktop` and `mobile` projects +- **Environment**: Ubuntu with Docker, TimescaleDB, and 12 fake miners +- **Reporting**: HTML reports and GitHub annotations + +See [`.github/workflows/protofleet-e2e-tests.yml`](/.github/workflows/protofleet-e2e-tests.yml) for full workflow configuration. + +### CI Test Execution + +The workflow: + +1. Builds ProtoFleet client (both ProtoOS and ProtoFleet apps) +2. Starts TimescaleDB service +3. Builds and runs 12 fake miners via Docker Compose +4. Runs backend server +5. Executes Playwright tests with `--project=desktop` or `--project=mobile` +6. Uploads test reports and traces as artifacts + +## Additional Resources + +- [Playwright Documentation](https://playwright.dev/) +- [Playwright Best Practices](https://playwright.dev/docs/best-practices) +- [Page Object Model Pattern](https://playwright.dev/docs/pom) +- [Debugging Tests](https://playwright.dev/docs/debug) + +## Contributing + +When adding new tests: + +1. Create appropriate page objects in `pages/` +2. Add fixtures if needed in `fixtures/` +3. Write descriptive test cases in `spec/` +4. Ensure tests pass locally before committing +5. Follow existing patterns and conventions diff --git a/client/e2eTests/protoFleet/config/test.config.defaults.ts b/client/e2eTests/protoFleet/config/test.config.defaults.ts new file mode 100644 index 000000000..bca9a3af4 --- /dev/null +++ b/client/e2eTests/protoFleet/config/test.config.defaults.ts @@ -0,0 +1,28 @@ +export const defaultTestConfig = { + baseUrl: "http://localhost:5173", + + /** + * Execution target for environment-specific behavior. + * - fake: local/dev environment using fake miners (default) + * - real: environment backed by real miners + */ + target: "fake" as "fake" | "real", + + users: { + admin: { + username: "admin", + password: "Pass123!", + }, + }, + + miners: { + username: "root", + password: "root", + }, + + testTimeout: 180000, + actionTimeout: 30000, + interval: 500, +}; + +export type TestConfig = typeof defaultTestConfig; diff --git a/client/e2eTests/protoFleet/config/test.config.local.example.ts b/client/e2eTests/protoFleet/config/test.config.local.example.ts new file mode 100644 index 000000000..29460eb13 --- /dev/null +++ b/client/e2eTests/protoFleet/config/test.config.local.example.ts @@ -0,0 +1,26 @@ +import type { TestConfig } from "./test.config.defaults"; + +/** + * Example local test configuration. + * HOW TO USE: + * Copy this file as test.config.local.ts and customize for your local environment. + * The local config file is gitignored and will not be committed. + * + * You can override any property from the default config. + * Your IDE will provide autocomplete for all available options. + */ +export const localTestConfig: Partial = { + // Example: Switch environment target (real miners skip auth flows) + // target: "real", + + // Example: Force the local fake-miners environment explicitly + // target: "fake", + + // Example: Override admin credentials + users: { + admin: { + username: "your-username", + password: "your-password", + }, + }, +}; diff --git a/client/e2eTests/protoFleet/config/test.config.ts b/client/e2eTests/protoFleet/config/test.config.ts new file mode 100644 index 000000000..6d63332dd --- /dev/null +++ b/client/e2eTests/protoFleet/config/test.config.ts @@ -0,0 +1,65 @@ +import { defaultTestConfig, type TestConfig } from "./test.config.defaults"; + +type E2ETarget = TestConfig["target"]; + +const fakeBaseUrl = "http://localhost:5173"; +const realBaseUrl = "http://localhost:8080"; + +function parseOptionalTarget(value: string | undefined): E2ETarget | undefined { + if (!value) { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "fake") { + return "fake"; + } + if (normalized === "real") { + return "real"; + } + + return undefined; +} + +let localConfig: Partial = {}; +try { + // Try to import local config if it exists + // @ts-expect-error - Local config file may not exist + const module = await import("./test.config.local"); + localConfig = module.localTestConfig || {}; +} catch { + // Local config doesn't exist, use defaults only +} + +// Merge default config with local overrides +const mergedConfig: TestConfig = { + ...defaultTestConfig, + ...localConfig, + users: { + ...defaultTestConfig.users, + ...localConfig.users, + }, + miners: { + ...defaultTestConfig.miners, + ...localConfig.miners, + }, +}; + +const rawTarget = process.env.E2E_TARGET; +const targetFromEnv = parseOptionalTarget(rawTarget); +if (rawTarget && !targetFromEnv) { + throw new Error(`Invalid E2E_TARGET value "${rawTarget}". Expected "fake" or "real".`); +} +const resolvedTarget: E2ETarget = targetFromEnv ?? mergedConfig.target; + +// Intentionally derived from target only (no base URL overrides). +const resolvedBaseUrl = resolvedTarget === "real" ? realBaseUrl : fakeBaseUrl; + +export const testConfig: TestConfig = { + ...mergedConfig, + baseUrl: resolvedBaseUrl, + target: resolvedTarget, +}; + +export const DEFAULT_TIMEOUT = testConfig.actionTimeout; +export const DEFAULT_INTERVAL = testConfig.interval; diff --git a/client/e2eTests/protoFleet/fixtures/pageFixtures.ts b/client/e2eTests/protoFleet/fixtures/pageFixtures.ts new file mode 100644 index 000000000..180260f51 --- /dev/null +++ b/client/e2eTests/protoFleet/fixtures/pageFixtures.ts @@ -0,0 +1,96 @@ +// NOTE: eslint incorrectly identifies 'use' as react hook +/* eslint-disable react-hooks/rules-of-hooks */ +import { test as base } from "@playwright/test"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AddMinersPage } from "../pages/addMiners"; +import { AuthPage } from "../pages/auth"; +import { LoginModalComponent } from "../pages/components/loginModal"; +import { EditPoolPage } from "../pages/editPool"; +import { GroupsPage } from "../pages/groups"; +import { HomePage } from "../pages/home"; +import { MinersPage } from "../pages/miners"; +import { NewPoolModalPage } from "../pages/newPoolModal"; +import { RacksPage } from "../pages/racks"; +import { SettingsPage } from "../pages/settings"; +import { SettingsApiKeysPage } from "../pages/settingsApiKeys"; +import { SettingsFirmwarePage } from "../pages/settingsFirmware"; +import { SettingsPoolsPage } from "../pages/settingsPools"; +import { SettingsSchedulesPage } from "../pages/settingsSchedules"; +import { SettingsSecurityPage } from "../pages/settingsSecurity"; +import { SettingsTeamPage } from "../pages/settingsTeam"; + +type PageFixtures = { + authPage: AuthPage; + homePage: HomePage; + minersPage: MinersPage; + groupsPage: GroupsPage; + racksPage: RacksPage; + addMinersPage: AddMinersPage; + settingsPage: SettingsPage; + settingsFirmwarePage: SettingsFirmwarePage; + settingsApiKeysPage: SettingsApiKeysPage; + settingsSchedulesPage: SettingsSchedulesPage; + settingsSecurityPage: SettingsSecurityPage; + settingsTeamPage: SettingsTeamPage; + settingsPoolsPage: SettingsPoolsPage; + editPoolPage: EditPoolPage; + newPoolModal: NewPoolModalPage; + loginModal: LoginModalComponent; + commonSteps: CommonSteps; +}; + +export const test = base.extend({ + authPage: async ({ page, isMobile }, use) => { + await use(new AuthPage(page, isMobile)); + }, + homePage: async ({ page, isMobile }, use) => { + await use(new HomePage(page, isMobile)); + }, + minersPage: async ({ page, isMobile }, use) => { + await use(new MinersPage(page, isMobile)); + }, + groupsPage: async ({ page, isMobile }, use) => { + await use(new GroupsPage(page, isMobile)); + }, + racksPage: async ({ page, isMobile }, use) => { + await use(new RacksPage(page, isMobile)); + }, + addMinersPage: async ({ page, isMobile }, use) => { + await use(new AddMinersPage(page, isMobile)); + }, + settingsPage: async ({ page, isMobile }, use) => { + await use(new SettingsPage(page, isMobile)); + }, + settingsFirmwarePage: async ({ page, isMobile }, use) => { + await use(new SettingsFirmwarePage(page, isMobile)); + }, + settingsApiKeysPage: async ({ page, isMobile }, use) => { + await use(new SettingsApiKeysPage(page, isMobile)); + }, + settingsSchedulesPage: async ({ page, isMobile }, use) => { + await use(new SettingsSchedulesPage(page, isMobile)); + }, + settingsSecurityPage: async ({ page, isMobile }, use) => { + await use(new SettingsSecurityPage(page, isMobile)); + }, + settingsTeamPage: async ({ page, isMobile }, use) => { + await use(new SettingsTeamPage(page, isMobile)); + }, + settingsPoolsPage: async ({ page, isMobile }, use) => { + await use(new SettingsPoolsPage(page, isMobile)); + }, + editPoolPage: async ({ page, isMobile }, use) => { + await use(new EditPoolPage(page, isMobile)); + }, + newPoolModal: async ({ page, isMobile }, use) => { + await use(new NewPoolModalPage(page, isMobile)); + }, + loginModal: async ({ page, isMobile }, use) => { + await use(new LoginModalComponent(page, isMobile)); + }, + commonSteps: async ({ authPage, minersPage }, use) => { + await use(new CommonSteps(authPage, minersPage)); + }, +}); + +export const expect = test.expect; diff --git a/client/e2eTests/protoFleet/helpers/commonSteps.ts b/client/e2eTests/protoFleet/helpers/commonSteps.ts new file mode 100644 index 000000000..c9065edad --- /dev/null +++ b/client/e2eTests/protoFleet/helpers/commonSteps.ts @@ -0,0 +1,28 @@ +import { test } from "@playwright/test"; +import { testConfig } from "../config/test.config"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; + +export class CommonSteps { + constructor( + private authPage: AuthPage, + private minersPage: MinersPage, + ) {} + + async loginAsAdmin() { + await test.step("Login as admin", async () => { + await this.authPage.inputUsername(testConfig.users.admin.username); + await this.authPage.inputPassword(testConfig.users.admin.password); + await this.authPage.clickLogin(); + await this.authPage.validateLoggedIn(); + }); + } + + async goToMinersPage() { + await test.step("Navigate to miners page", async () => { + await this.minersPage.navigateToMinersPage(); + await this.minersPage.waitForMinersTitle(); + await this.minersPage.waitForMinersListToLoad(); + }); + } +} diff --git a/client/e2eTests/protoFleet/helpers/minerModels.ts b/client/e2eTests/protoFleet/helpers/minerModels.ts new file mode 100644 index 000000000..06c3db3ad --- /dev/null +++ b/client/e2eTests/protoFleet/helpers/minerModels.ts @@ -0,0 +1,2 @@ +export const PROTO_RIG_MODEL = "Rig"; +export const PROTO_RIG_DISPLAY_NAME = "Proto Rig"; diff --git a/client/e2eTests/protoFleet/helpers/testDataHelper.ts b/client/e2eTests/protoFleet/helpers/testDataHelper.ts new file mode 100644 index 000000000..b95f84046 --- /dev/null +++ b/client/e2eTests/protoFleet/helpers/testDataHelper.ts @@ -0,0 +1,19 @@ +export function generateRandomText(prefix: string): string { + const randomCode = Math.random().toString(36).substring(2, 9); + return `${prefix}_${randomCode}`; +} + +export function generateRandomUsername(): string { + return generateRandomText("username"); +} + +// Issue icon IDs for miner issues column +export const IssueIcon = { + CONTROL_BOARD: "control-board-icon", + HASH_BOARD: "hashboard-icon", + PSU: "lightning-alt-icon", + FAN: "fan-icon", + GENERAL_ALERT: "alert-icon", +} as const; + +export type IssueIconId = (typeof IssueIcon)[keyof typeof IssueIcon]; diff --git a/client/e2eTests/protoFleet/pages/addMiners.ts b/client/e2eTests/protoFleet/pages/addMiners.ts new file mode 100644 index 000000000..850c7d75e --- /dev/null +++ b/client/e2eTests/protoFleet/pages/addMiners.ts @@ -0,0 +1,144 @@ +import { expect, type Locator } from "@playwright/test"; +import { PROTO_RIG_DISPLAY_NAME } from "../helpers/minerModels"; +import { BasePage } from "./base"; + +export class AddMinersPage extends BasePage { + async clickFindMinersInNetwork() { + await this.clickIn("Find miners", "section-scan-network"); + } + + async clickFindMinersByIp() { + await this.clickIn("Find miners", "section-search-by-ip"); + } + + async inputMinerIp(ipAddresses: string) { + await this.page.locator('//textarea[@id="ipAddresses"]').fill(ipAddresses); + } + + async clickChooseMiners() { + await this.clickButton("Choose miners"); + } + + async clickSelectAllCheckboxInModal() { + await this.page.getByTestId("modal").getByTestId("select-all-checkbox").click(); + } + + async clickSelectNone() { + await this.clickButton("Select none"); + } + + async getMinerIpAddressByIndex(index: number): Promise { + const rows = this.page.getByTestId("modal").getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + return await row.getByTestId("ipAddress").innerText(); + } + + async getMinerRowByIp(ipAddress: string): Promise { + return this.page + .getByTestId("modal") + .locator(`//tr[child::*[@data-testid="ipAddress" and descendant::text()='${ipAddress}']]`); + } + + async clickMinerCheckbox(ipAddress: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.locator('input[type="checkbox"]').click(); + } + + async clickDone() { + await this.clickButton("Done"); + } + + async clickContinueWithXMiners(minerCount: number) { + await this.page.getByRole("button", { name: `Continue with ${minerCount} miners` }).click(); + } + + async clickContinueWithSelectedMiners() { + await this.page.getByRole("button", { name: /Continue with \d+ miner(s)?/ }).click(); + } + + async waitForFoundMinersList() { + const foundMinersList = this.page.getByTestId("found-miners-list"); + await expect(foundMinersList).toBeVisible(); + } + + async getFoundMinersCount(): Promise { + const minerRows = this.page.getByTestId("miner-model-row"); + return await minerRows.count(); + } + + async clickHeaderIconButton() { + await this.page.getByTestId("header-icon-button").click(); + } + + async validateOneMinerWasFoundByIp() { + const foundMessage = this.page.getByText("1 miners found on your network"); + await expect(foundMessage).toBeVisible(); + + const minerRows = this.page.getByTestId("miner-model-row"); + await expect(minerRows).toHaveCount(1); + + const firstMinerRow = minerRows.first(); + await expect(firstMinerRow).toContainText(PROTO_RIG_DISPLAY_NAME); + await expect(firstMinerRow).toContainText("1 miners"); + + const continueButton = this.page.getByRole("button", { name: "Continue with 1 miners" }); + await expect(continueButton).toBeVisible(); + } + + async validateValidationErrorDialogIsVisible() { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Some entries not recognized")).toBeVisible(); + } + + async validateValidationErrorDialogIsClosed() { + await expect(this.page.getByTestId("validation-error-dialog")).toBeHidden(); + } + + async validateInvalidIpAddressesInDialog(entries: string[]) { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByText("Invalid IP addresses")).toBeVisible(); + for (const entry of entries) { + await expect(dialog.getByText(entry)).toBeVisible(); + } + } + + async validateInvalidIpRangesInDialog(entries: string[]) { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByText("Invalid IP ranges")).toBeVisible(); + for (const entry of entries) { + await expect(dialog.getByText(entry)).toBeVisible(); + } + } + + async validateInvalidSubnetsInDialog(entries: string[]) { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByText("Invalid subnet blocks")).toBeVisible(); + for (const entry of entries) { + await expect(dialog.getByText(entry)).toBeVisible(); + } + } + + async clickBackToEditing() { + await this.page.getByTestId("validation-error-dialog").getByRole("button", { name: "Back to editing" }).click(); + } + + async clickContinueAnyway() { + await this.page.getByTestId("validation-error-dialog").getByRole("button", { name: "Continue anyway" }).click(); + } + + async validateContinueAnywayButtonNotVisible() { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByRole("button", { name: "Continue anyway" })).toBeHidden(); + } + + async validateContinueAnywayButtonVisible() { + const dialog = this.page.getByTestId("validation-error-dialog"); + await expect(dialog.getByRole("button", { name: "Continue anyway" })).toBeVisible(); + } + + async validateTextareaErrorContains(text: string) { + const errorElement = this.page.getByTestId("ipAddresses-validation-error"); + await expect(errorElement).toContainText(text); + } +} diff --git a/client/e2eTests/protoFleet/pages/auth.ts b/client/e2eTests/protoFleet/pages/auth.ts new file mode 100644 index 000000000..45f9ea695 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/auth.ts @@ -0,0 +1,64 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class AuthPage extends BasePage { + async inputUsername(username: string) { + await this.page.locator(`//input[@id='username']`).fill(username); + } + + async inputPassword(password: string) { + await this.page.locator(`//input[@id='password']`).fill(password); + } + + async clickLogin() { + await this.page.locator(`//button[@data-testid="login-button"]`).click(); + } + + async validateRedirectedToAuth() { + await expect(this.page).toHaveURL(/.*\/auth/); + } + + async inputNewPassword(password: string) { + await this.page.locator(`//input[@id='newPassword']`).fill(password); + } + + async inputConfirmPassword(password: string) { + await this.page.locator(`//input[@id='confirmPassword']`).fill(password); + } + + async clickContinue() { + await this.clickButton("Continue"); + } + + async clickLoginButton() { + await this.clickButton("Login"); + } + + async clickPasswordVisibilityToggle() { + await this.page.locator(`//*[@data-testid="eye-icon"]`).click(); + } + + async validateInvalidCredentials() { + await expect(this.page.getByText("Invalid credentials entered.")).toBeVisible(); + } + + async validateUpdatePasswordTitle() { + await this.validateTitle("Update Your Password"); + } + + async validatePasswordSaved() { + await this.validateTitle("Password saved"); + } + + async clickCreateAccount() { + await this.clickButton("Create an account"); + } + + async validateCreateCredentialsPrompt() { + await expect(this.page.getByText("Create your username and password")).toBeVisible(); + } + + async clickGetStarted() { + await this.clickButton("Get started"); + } +} diff --git a/client/e2eTests/protoFleet/pages/base.ts b/client/e2eTests/protoFleet/pages/base.ts new file mode 100644 index 000000000..95e7ad417 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/base.ts @@ -0,0 +1,246 @@ +import { expect, Page } from "@playwright/test"; +import { DEFAULT_TIMEOUT, testConfig } from "../config/test.config"; + +export class BasePage { + constructor( + protected page: Page, + protected isMobile: boolean = false, + ) {} + + async reloadPage() { + await this.page.reload(); + } + + async validateLoggedIn(timeout: number = DEFAULT_TIMEOUT) { + if (this.isMobile) { + await expect(this.page.getByTestId("navigation-menu-button")).toBeVisible({ timeout }); + } else { + await expect(this.page.getByTestId("logout-button")).toBeVisible({ timeout }); + } + } + + async logout() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("logout-button").click(); + } + + async validateTitle(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()='${expectedTitle}']`); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleInModal(expectedTitle: string) { + const titleLocator = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()='${expectedTitle}']`, + ); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleNotVisible(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()='${expectedTitle}']`); + await expect(titleLocator).toBeHidden(); + } + + async validateTitleInModalNotVisible(expectedTitle: string) { + const titleLocator = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()='${expectedTitle}']`, + ); + await expect(titleLocator).toBeHidden(); + } + + async validateTextIsVisible(text: string) { + await expect(this.page.getByText(text)).toBeVisible(); + } + + async validateTextInToast(text: string) { + const toast = this.page.getByTestId("toast").getByText(text); + await expect(toast).toBeVisible(); + } + + async validateTextInToastGroup(text: string) { + const groupedHeaderMessage = this.page.getByTestId("grouped-toaster-header").getByText(text).first(); + const groupedBodyMessage = this.page.getByTestId("toaster-container").getByTestId("toast").getByText(text).first(); + + await expect + .poll( + async () => + (await groupedHeaderMessage.isVisible().catch(() => false)) || + (await groupedBodyMessage.isVisible().catch(() => false)), + { + timeout: DEFAULT_TIMEOUT, + }, + ) + .toBe(true); + } + + async dismissToast() { + const toast = this.page.getByTestId("toaster-container"); + const dismissButton = this.page.getByRole("button", { name: "Dismiss" }); + if (!(await dismissButton.isVisible())) { + await toast.click(); + } + await toast.getByRole("button", { name: "Dismiss" }).click(); + } + + async validateTextInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeVisible(); + } + + async validateTextNotInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeHidden(); + } + + async validateButtonIsVisible(text: string) { + await expect(this.page.getByRole("button", { name: text })).toBeVisible(); + } + + async clickNavigationMenuIfMobile() { + if (this.isMobile) { + await this.page.getByTestId("navigation-menu-button").click(); + } + } + + async clickExpandSettingsIfMobile() { + if (this.isMobile && !this.page.url().includes("/settings")) { + await this.page.getByTestId("navigation-menu").getByText("Settings").click(); + } + } + + async navigateToHomePage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/"]').click(); + await expect(this.page).toHaveURL(/.*\/$/); + } + + async navigateToMinersPage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/miners"]').click(); + await expect(this.page).toHaveURL(/.*\/miners/); + } + + async navigateToGroupsPage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/groups"]').click(); + await expect(this.page).toHaveURL(/.*\/groups/); + } + + async navigateToRacksPage() { + await this.clickNavigationMenuIfMobile(); + await this.page.getByTestId("navigation-menu").locator('a[href="/racks"]').click(); + await expect(this.page).toHaveURL(/.*\/racks/); + } + + async navigateToSettingsPage() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + if (this.isMobile) { + await this.page.getByTestId("navigation-menu").locator('a[href="/settings/general"]').click(); + } else { + await this.page.getByTestId("navigation-menu").locator('a[href="/settings"]').click(); + } + await expect(this.page).toHaveURL(/.*\/settings/); + } + + async navigateSettingsIfDesktop() { + // desktop can't navigate directly to subpages of settings + if (!this.isMobile && !this.page.url().includes("/settings")) { + await this.navigateToSettingsPage(); + } + } + + async navigateToSecuritySettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/security"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/security/); + } + + async navigateToTeamSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/team"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/team/); + } + + async navigateToMiningPoolsSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/mining-pools"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/mining-pools/); + } + + async navigateToFirmwareSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/firmware"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/firmware/); + } + + async navigateToApiKeysSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/api-keys"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/api-keys/); + } + + async navigateToSchedulesSettings() { + await this.clickNavigationMenuIfMobile(); + await this.clickExpandSettingsIfMobile(); + await this.navigateSettingsIfDesktop(); + await this.page.getByTestId("secondary-nav").locator('a[href="/settings/schedules"]').click(); + await expect(this.page).toHaveURL(/.*\/settings\/schedules/); + } + + async clickButton(text: string) { + await this.page.getByRole("button", { name: text, disabled: false, exact: true }).click(); + } + + async clickUntilNotVisible(text: string) { + const button = this.page.getByRole("button", { name: text, disabled: false, exact: true }); + + await expect(button).toBeVisible(); + await expect(async () => { + const isVisible = await button.isVisible(); + if (isVisible) { + await button.click(); + throw new Error("Button still visible, looping until it is not or the time runs out"); + } + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [100] }); + } + + async clickIn(text: string, testId: string) { + await this.page.getByTestId(testId).getByRole("button", { name: text, disabled: false, exact: true }).click(); + } + + async validateModalIsOpen() { + await expect(this.page.getByTestId("modal")).toBeVisible(); + } + + async validateModalIsClosed() { + await expect(this.page.getByTestId("modal")).toBeHidden(); + } + + async clickSaveInModal() { + await this.clickIn("Save", "modal"); + } + + // Helper method to try an action with timeout and return success/failure + // Useful in cases where we are not sure in what state the system is at a particular moment, e.g. during cleanup + async tryAction(action: () => Promise, timeoutMs: number = 3000): Promise { + const originalTimeout = testConfig.actionTimeout; + this.page.setDefaultTimeout(timeoutMs); + try { + await action(); + return true; + } catch { + return false; + } finally { + this.page.setDefaultTimeout(originalTimeout); + } + } +} diff --git a/client/e2eTests/protoFleet/pages/components/loginModal.ts b/client/e2eTests/protoFleet/pages/components/loginModal.ts new file mode 100644 index 000000000..909eb8156 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/components/loginModal.ts @@ -0,0 +1,16 @@ +import { testConfig } from "../../config/test.config"; +import { BasePage } from "../base"; + +export class LoginModalComponent extends BasePage { + async loginAsAdmin() { + const headingText = "Log in to update your pool settings"; + await this.validateTitleInModal(headingText); + const modal = this.page.getByTestId("modal"); + + await modal.locator("xpath=.//input[@id='username']").fill(testConfig.users.admin.username); + await modal.locator("xpath=.//input[@id='password']").fill(testConfig.users.admin.password); + await modal.getByRole("button", { name: "Continue" }).click(); + + await this.validateTitleInModalNotVisible(headingText); + } +} diff --git a/client/e2eTests/protoFleet/pages/components/modalMinerSelectionList.ts b/client/e2eTests/protoFleet/pages/components/modalMinerSelectionList.ts new file mode 100644 index 000000000..7e32ca7cd --- /dev/null +++ b/client/e2eTests/protoFleet/pages/components/modalMinerSelectionList.ts @@ -0,0 +1,95 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../../config/test.config"; + +export class ModalMinerSelectionList { + constructor(private readonly root: Locator) {} + + private get rows(): Locator { + return this.root.getByTestId("list-row"); + } + + async waitForListToLoad({ allowEmpty = false }: { allowEmpty?: boolean } = {}) { + if (!allowEmpty) { + await expect(this.rows).not.toHaveCount(0); + } + + await expect(async () => { + const rowCount = await this.rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await this.rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async getRowCount(): Promise { + return await this.rows.count(); + } + + async getSelectableRowIndexes(count: number): Promise { + const rowCount = await this.rows.count(); + const indexes: number[] = []; + + for (let i = 0; i < rowCount; i++) { + const input = this.rows.nth(i).getByTestId("checkbox").locator("input").first(); + if (!(await input.isDisabled())) { + indexes.push(i); + } + + if (indexes.length === count) { + break; + } + } + + return indexes; + } + + async clickSelectAllCheckbox() { + await this.root.getByTestId("select-all-checkbox").locator('input[type="checkbox"]').click(); + } + + async selectRowsByIndex(indexes: number[]) { + for (const index of indexes) { + const row = this.rows.nth(index); + await row.scrollIntoViewIfNeeded(); + await row.getByTestId("checkbox").locator("input").first().click(); + } + } + + async getCellTextByIndex(index: number, cellTestId: string): Promise { + return (await this.rows.nth(index).getByTestId(cellTestId).innerText()).trim(); + } + + async getVisibleCellTexts(cellTestId: string): Promise { + const cells = this.rows.getByTestId(cellTestId); + const count = await cells.count(); + const values: string[] = []; + + for (let i = 0; i < count; i++) { + values.push((await cells.nth(i).innerText()).trim()); + } + + return values; + } + + async selectRowByCellText(cellTestId: string, text: string) { + const rowCount = await this.rows.count(); + + for (let i = 0; i < rowCount; i++) { + const row = this.rows.nth(i); + if ((await row.getByTestId(cellTestId).innerText()).trim() !== text) { + continue; + } + + await row.scrollIntoViewIfNeeded(); + await row.getByTestId("checkbox").locator("input").first().click(); + return; + } + + throw new Error(`Could not find a list row with ${cellTestId}="${text}"`); + } + + async validateCellTextByIndex(index: number, cellTestId: string, expectedText: string) { + await expect(this.rows.nth(index).getByTestId(cellTestId)).toHaveText(expectedText); + } +} diff --git a/client/e2eTests/protoFleet/pages/editPool.ts b/client/e2eTests/protoFleet/pages/editPool.ts new file mode 100644 index 000000000..714356bed --- /dev/null +++ b/client/e2eTests/protoFleet/pages/editPool.ts @@ -0,0 +1,78 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class EditPoolPage extends BasePage { + async clickAddPoolButton() { + await this.page.getByTestId("add-pool-button").click(); + } + + async clickAddAnotherPoolButton() { + await this.page.getByTestId("add-another-pool-button").click(); + } + + async clickAddDefaultMiningPool() { + await this.clickIn("Add pool", "default-pool"); + } + + async clickAddBackupPoolOne() { + await this.clickIn("Add pool", "backup-pool-1"); + } + + async clickPoolRowByName(name: string) { + await this.page.getByText(name).click(); + } + + async clickSavePoolChoice() { + await this.clickSaveInModal(); + } + + async clickAddNewPool() { + await this.clickIn("Add new pool", "modal"); + } + + async clickAssignToXMiners(count: number | Promise) { + const minerCount = await Promise.resolve(count); + const buttonText = `Assign to ${minerCount} miner${minerCount === 1 ? "" : "s"}`; + await this.clickButton(buttonText); + } + + async getPoolNameByIndex(index: number): Promise { + const poolRow = this.page.getByTestId(`pool-row-${index}`); + return await poolRow.getByTestId("pool-name").innerText(); + } + + async getPoolUrlByIndex(index: number): Promise { + const poolRow = this.page.getByTestId(`pool-row-${index}`); + return await poolRow.getByTestId("pool-url").innerText(); + } + + async validatePoolCount(count: number) { + const poolRows = this.page.getByTestId(/^pool-row-\d+$/); + await expect(poolRows).toHaveCount(count); + } + + async validatePoolByIndex(index: number, name: string, url: string) { + const poolRow = this.page.getByTestId(`pool-row-${index}`); + await expect(poolRow.getByTestId("pool-name")).toHaveText(name); + await expect(poolRow.getByTestId("pool-url")).toHaveText(url); + } + + async reorderPoolByDragging(fromIndex: number, toIndex: number) { + const sourceHandle = this.page.getByTestId(`pool-row-${fromIndex}`).getByTestId("reorder-handle"); + const targetHandle = this.page.getByTestId(`pool-row-${toIndex}`).getByTestId("reorder-handle"); + await sourceHandle.dragTo(targetHandle, { steps: 20 }); + } + + async removeAllPools() { + const poolRows = this.page.getByTestId(/^pool-row-\d+$/); + const poolCount = await poolRows.count(); + + for (let i = 0; i < poolCount; i++) { + const firstRow = poolRows.first(); + await firstRow.getByRole("button", { name: "Pool actions", exact: true }).click(); + await this.clickButton("Remove"); + await expect(poolRows).toHaveCount(poolCount - 1 - i); + } + await expect(poolRows).toHaveCount(0); + } +} diff --git a/client/e2eTests/protoFleet/pages/groups.ts b/client/e2eTests/protoFleet/pages/groups.ts new file mode 100644 index 000000000..5945f2147 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/groups.ts @@ -0,0 +1,253 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; +import { ModalMinerSelectionList } from "./components/modalMinerSelectionList"; + +const EMPTY_GROUP_PLACEHOLDER = "—"; + +export class GroupsPage extends BasePage { + private readonly modalMinerList = new ModalMinerSelectionList(this.page.getByTestId("modal")); + + private async clickLocator(locator: Locator) { + try { + await locator.click({ timeout: 2000 }); + } catch { + await locator.evaluate((node) => { + (node as HTMLElement).click(); + }); + } + } + + async waitForSavedGroupsListToLoad() { + const rows = this.page.getByTestId("list-row"); + + await expect(this.page.getByRole("button", { name: "Add group" })).toBeVisible(); + await expect(async () => { + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + private async clickDropdownFilterOption(popover: Locator, optionNames: string[]) { + for (const optionName of optionNames) { + const optionByTestId = popover.getByTestId(`filter-option-${optionName}`).first(); + if (await optionByTestId.isVisible().catch(() => false)) { + await optionByTestId.evaluate((node) => { + node.scrollIntoView({ block: "center", inline: "nearest" }); + }); + await this.clickLocator(optionByTestId); + return; + } + + const optionByText = popover.getByText(optionName, { exact: true }).first(); + if (await optionByText.isVisible().catch(() => false)) { + await optionByText.evaluate((node) => { + node.scrollIntoView({ block: "center", inline: "nearest" }); + }); + await this.clickLocator(optionByText); + return; + } + } + + throw new Error(`Unable to find filter option. Tried: ${optionNames.join(", ")}`); + } + + async clickAddGroupButton() { + await this.clickButton("Add group"); + await this.validateModalIsOpen(); + } + + async closeModal() { + await this.page.getByTestId("modal").getByTestId("header-icon-button").click(); + await this.validateModalIsClosed(); + } + + async openSavedGroup(groupName: string) { + const groupRow = this.getGroupRow(groupName); + await expect(groupRow).toBeVisible(); + + await groupRow.getByLabel("Device set actions").click(); + await this.clickButton("Edit group"); + await this.validateModalIsOpen(); + } + + async inputGroupName(groupName: string) { + await this.page.locator(`//input[@id='group-name']`).fill(groupName); + } + + async clickSelectAllCheckboxInModal() { + await this.modalMinerList.clickSelectAllCheckbox(); + } + + async waitForModalListToLoad() { + await this.modalMinerList.waitForListToLoad(); + } + + async getModalListRowCount(): Promise { + return await this.modalMinerList.getRowCount(); + } + + async selectMinersByIndex(indexes: number[]) { + await this.modalMinerList.selectRowsByIndex(indexes); + } + + async validateMinerGroupsByIndex(index: number, expectedGroups: string) { + const groupCell = this.page.getByTestId("modal").getByTestId("list-row").nth(index).getByTestId("group"); + await expect(groupCell).toHaveText(expectedGroups); + } + + async getModalRowGroupByIndex(index: number): Promise { + const groupText = await this.modalMinerList.getCellTextByIndex(index, "group"); + return groupText === EMPTY_GROUP_PLACEHOLDER ? "" : groupText; + } + + async getModalRowIpAddressByIndex(index: number): Promise { + return await this.modalMinerList.getCellTextByIndex(index, "ipAddress"); + } + + async getUngroupedMinerIps(limit: number): Promise { + const rowCount = await this.getModalListRowCount(); + const minerIps: string[] = []; + + for (let i = 0; i < rowCount && minerIps.length < limit; i++) { + if ((await this.getModalRowGroupByIndex(i)) !== "") { + continue; + } + minerIps.push(await this.getModalRowIpAddressByIndex(i)); + } + + return minerIps; + } + + async selectMinerByIp(ipAddress: string) { + await this.modalMinerList.selectRowByCellText("ipAddress", ipAddress); + } + + async validateMinerGroupsByIp(ipAddress: string, expectedGroups: string) { + const groupCell = this.page + .getByTestId("modal") + .getByTestId("list-row") + .filter({ has: this.page.getByTestId("ipAddress").getByText(ipAddress, { exact: true }) }) + .first() + .getByTestId("group"); + await expect(groupCell).toHaveText(expectedGroups); + } + + async getModalVisibleIpAddresses(): Promise { + return await this.modalMinerList.getVisibleCellTexts("ipAddress"); + } + + async validateOnlyTheseIpsVisibleInModal(expectedIps: string[]) { + const visibleIps = await this.getModalVisibleIpAddresses(); + expect(visibleIps).toHaveLength(expectedIps.length); + const expectedSet = new Set(expectedIps); + for (const ip of visibleIps) { + expect(expectedSet.has(ip)).toBe(true); + } + } + + async filterModalType(type: string) { + await this.clickLocator(this.page.getByTestId("modal").getByTestId("filter-dropdown-Model")); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + await this.clickDropdownFilterOption(popover, [type]); + await this.clickLocator(popover.getByRole("button", { name: "Apply" })); + await expect(popover).toBeHidden(); + } + + async filterModalGroup(groupName: string) { + await this.page.getByTestId("modal").getByTestId("filter-dropdown-Group").click(); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + + const resetButton = popover.getByRole("button", { name: "Reset" }); + await resetButton.click(); + + await popover.getByText(groupName, { exact: true }).click(); + await popover.getByRole("button", { name: "Apply" }).click(); + await expect(popover).toBeHidden(); + } + + async clickDeleteGroupInModal() { + await this.clickIn("Delete group", "modal"); + } + + async clickDeleteConfirm() { + await this.clickButton("Delete"); + } + + async validateErrorMessage(text: string) { + await expect(this.page.getByTestId("error-msg")).toHaveText(text); + } + + async validateSavedGroupVisible(groupName: string) { + await expect(this.getGroupRow(groupName)).toBeVisible(); + } + + async validateSavedGroupNotVisible(groupName: string) { + await expect(this.getGroupRow(groupName)).toBeHidden(); + } + + async validateSavedGroupMinerCount(groupName: string, minerCount: number) { + await expect(this.getGroupRow(groupName).getByTestId("miners")).toHaveText(`${minerCount}`); + } + + async getSavedGroupCount(): Promise { + const rows = this.page.getByTestId("list-row"); + return await rows.count(); + } + + async listSavedGroupNames(): Promise { + await this.waitForSavedGroupsListToLoad(); + + const nameCells = this.page.getByTestId("list-row").getByTestId("name"); + const count = await nameCells.count(); + const names: string[] = []; + for (let i = 0; i < count; i++) { + names.push((await nameCells.nth(i).innerText()).trim()); + } + return names; + } + + async deleteSavedGroupIfVisible(groupName: string) { + const groupRow = this.getGroupRow(groupName); + if (!(await groupRow.isVisible().catch(() => false))) { + return; + } + + await this.openSavedGroup(groupName); + await this.clickDeleteGroupInModal(); + await this.clickDeleteConfirm(); + await this.validateSavedGroupNotVisible(groupName); + } + + private getGroupRow(groupName: string) { + return this.page + .getByTestId("list-row") + .filter({ has: this.page.getByTestId("name").getByText(groupName, { exact: true }) }) + .first(); + } + + async clickGroupActionsButton(groupName: string) { + const groupRow = this.getGroupRow(groupName); + await expect(groupRow).toBeVisible(); + await groupRow.getByLabel("Device set actions").click(); + } + + async clickRebootGroupButton() { + await this.page.getByTestId("reboot-popover-button").click(); + } + + async validateRebootConfirmationModal(minerCount: number) { + await this.validateTitle(`Reboot ${minerCount} miners?`); + } + + async clickRebootConfirm() { + await this.clickButton("Reboot"); + } +} diff --git a/client/e2eTests/protoFleet/pages/home.ts b/client/e2eTests/protoFleet/pages/home.ts new file mode 100644 index 000000000..4083f410d --- /dev/null +++ b/client/e2eTests/protoFleet/pages/home.ts @@ -0,0 +1,122 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class HomePage extends BasePage { + private getOverviewIssueCard(title: string) { + return this.page + .locator(`//*[self::a or self::div][contains(@class,'rounded-xl')][.//*[normalize-space(text())='${title}']]`) + .first(); + } + + private getOverviewIssueLink(title: string) { + return this.page.locator(`//a[contains(@class,'rounded-xl')][.//*[normalize-space(text())='${title}']]`); + } + + async validateCompleteSetupTitle() { + await this.validateTitle("Complete setup"); + } + + async validateHomePageOpened() { + await expect(this.page).toHaveURL(/.*\/$/); + } + + async clickAuthenticateMinersButton() { + await this.clickButton("Authenticate"); + } + + async validateAuthenticateMinersModalTitle() { + await this.validateTitleInModal("Authenticate miners"); + } + + async inputMinerAuthUsername(username: string) { + await this.page.locator(`//input[@id='username']`).fill(username); + } + + async inputMinerAuthPassword(password: string) { + await this.page.locator(`//input[@id='password']`).fill(password); + } + + async clickAuthenticateMinersConfirmButton() { + await this.page.getByTestId("modal").getByRole("button", { name: "Authenticate" }).click(); + } + + async validateCompleteSetupTitleNotVisible() { + await this.validateTitleNotVisible("Complete setup"); + } + + async validateAuthenticateMinersButtonNotVisible() { + await expect(this.page.getByRole("button", { name: "Authenticate" })).toBeHidden(); + } + + async clickControlBoardsLink() { + await this.page.getByRole("link", { name: "Control Boards" }).click(); + } + + async clickFansLink() { + await this.page.getByRole("link", { name: "Fans" }).click(); + } + + async clickHashboardsLink() { + await this.page.getByRole("link", { name: "Hashboards" }).click(); + } + + async clickPowerSuppliesLink() { + await this.page.getByRole("link", { name: "Power supplies" }).click(); + } + + async validateOverviewIssueCard(title: string, statusText: string) { + const card = this.getOverviewIssueCard(title); + await expect(card).toBeVisible(); + await expect(card).toContainText(statusText); + } + + async validateOverviewIssueCardIsNotClickable(title: string) { + await expect(this.getOverviewIssueLink(title)).toHaveCount(0); + } + + async getListOfMinersToAuthenticate(): Promise { + return this.page.getByTestId("modal").getByTestId("model").allTextContents(); + } + + async clickShowMinersButton() { + await this.page.getByTestId("modal").getByRole("button", { name: "Show miners" }).click(); + } + + async validateCalloutInModal(text: string) { + await expect(this.page.getByTestId("modal").locator("[data-testid*='callout']").getByText(text)).toBeVisible(); + } + + async validateNoCalloutInModal() { + await expect(this.page.getByTestId("modal").locator("[data-testid*='callout']")).toBeHidden(); + } + + async clickCalloutButton() { + await this.page.getByTestId("modal").locator("[data-testid*='callout']").getByRole("button").click(); + } + + async getMinerRowByModel(model: string) { + return this.page + .getByTestId("modal") + .locator("tr") + .filter({ has: this.page.getByTestId("model").getByText(model) }); + } + + async clickMinerAuthCheckbox(model: string) { + const row = await this.getMinerRowByModel(model); + await row.locator('input[type="checkbox"]').click(); + } + + async inputMinerRowUsername(model: string, username: string) { + const row = await this.getMinerRowByModel(model); + await row.getByTestId("username").locator("input").fill(username); + } + + async inputMinerRowPassword(model: string, password: string) { + const row = await this.getMinerRowByModel(model); + await row.getByTestId("password").locator("input").fill(password); + } + + async validateModalClosed() { + await expect(this.page.getByTestId("modal")).toBeHidden(); + } +} diff --git a/client/e2eTests/protoFleet/pages/miners.ts b/client/e2eTests/protoFleet/pages/miners.ts new file mode 100644 index 000000000..91f663d4f --- /dev/null +++ b/client/e2eTests/protoFleet/pages/miners.ts @@ -0,0 +1,940 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { PROTO_RIG_DISPLAY_NAME, PROTO_RIG_MODEL } from "../helpers/minerModels"; +import { type IssueIconId } from "../helpers/testDataHelper"; +import { BasePage } from "./base"; + +const PROLONGED_TIMEOUT = DEFAULT_TIMEOUT * 4; + +export class MinersPage extends BasePage { + private async clickDropdownFilterOption(popover: Locator, optionNames: string[]) { + for (const optionName of optionNames) { + const optionByTestId = popover.getByTestId(`filter-option-${optionName}`).first(); + if (await optionByTestId.isVisible().catch(() => false)) { + await optionByTestId.click(); + return; + } + + const optionByText = popover.getByText(optionName, { exact: true }).first(); + if (await optionByText.isVisible().catch(() => false)) { + await optionByText.click(); + return; + } + } + + throw new Error(`Unable to find filter option. Tried: ${optionNames.join(", ")}`); + } + + async validateMinersPageOpened() { + await this.validateTitle("Miners"); + } + + async validateAmountOfMiners(minerCount: number) { + const rows = this.page.getByTestId("list-body").locator("tr"); + await expect(rows).toHaveCount(minerCount); + } + + async validateMinersAdded(minerCount: number = 5) { + const rows = this.page.getByTestId("list-body").locator("tr"); + expect(await rows.count()).toBeGreaterThanOrEqual(minerCount); + } + + private async filterMinersByModel(minerType: string) { + await this.page.getByTestId("filter-dropdown-Model").click(); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + await this.clickDropdownFilterOption(popover, [minerType]); + await popover.getByRole("button", { name: "Apply" }).click(); + await expect(popover).toBeHidden(); + } + + async filterRigMiners() { + await this.filterMinersByModel(PROTO_RIG_MODEL); + await this.waitForAntminersToDisappear(); + } + + async filterAllMinersExceptRig() { + await this.page.getByTestId("filter-dropdown-Model").click(); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("opacity", "1"); + await popover.getByText("Select all", { exact: true }).click(); + await this.clickDropdownFilterOption(popover, [PROTO_RIG_MODEL]); + + await popover.getByRole("button", { name: "Apply" }).click(); + await expect(popover).toBeHidden(); + await this.waitForRigMinersToDisappear(); + } + + async waitForAntminersToDisappear() { + const antminerRows = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ has: this.page.getByTestId("name").getByText("Antminer") }); + await expect(antminerRows).toHaveCount(0); + } + + async waitForRigMinersToDisappear() { + const rigRows = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ has: this.page.getByTestId("name").getByText(PROTO_RIG_DISPLAY_NAME, { exact: true }) }); + await expect(rigRows).toHaveCount(0); + } + + async getMinerRowByIp(ipAddress: string): Promise { + return this.page.locator(`//tr[child::*[@data-testid="ipAddress" and descendant::text()='${ipAddress}']]`); + } + + async validateMinerInList(ipAddress: string) { + await expect(await this.getMinerRowByIp(ipAddress)).toBeVisible(); + } + + async validateMinerValue(minerName: string, columnTestId: string, expectedValue: string) { + const minerRow = await this.getMinerRowByIp(minerName); + const columnLocator = minerRow.locator(`//td[@data-testid='${columnTestId}']`); + await expect(columnLocator).toHaveText(expectedValue); + } + + async validateMinerIcon(minerIp: string, columnTestId: string, iconId: IssueIconId) { + const minerRow = await this.getMinerRowByIp(minerIp); + const columnLocator = minerRow.locator(`//td[@data-testid='${columnTestId}']`); + await expect(columnLocator.getByTestId(iconId)).toBeVisible(); + } + + async clickMinerThreeDotsButton(ipAddress: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.getByTestId(`single-miner-actions-menu-button`).click(); + } + async clickMinerCheckbox(ipAddress: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.locator(`//input[@type='checkbox']`).click(); + } + + async clickMinerCheckboxByIndex(index: number) { + const rows = this.page.getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + await row.scrollIntoViewIfNeeded(); + await row.locator('input[type="checkbox"]').first().click(); + } + + async waitForMinersTitle() { + await this.validateTitle("Miners"); + } + + async clickSelectAllCheckbox() { + await this.page.getByTestId("list-header").locator('input[type="checkbox"]').click(); + } + + async uncheckSelectAllCheckbox() { + const checkbox = this.page.getByTestId("list-header").locator('input[type="checkbox"]'); + if (await checkbox.isChecked()) { + await checkbox.click(); + } + } + + async clickActionsMenuButton() { + await this.page.getByTestId("actions-menu-button").click(); + } + + async validateActionBarMinerCount(expectedCount: number) { + await expect(this.page.getByTestId("action-bar")).toBeVisible(); + if (expectedCount === 1) { + await expect(this.page.getByTestId("action-bar").getByText("1 miner selected")).toBeVisible(); + } else { + await expect(this.page.getByTestId("action-bar").getByText(`${expectedCount} miners selected`)).toBeVisible(); + } + } + + async clickRebootButton() { + await this.page.getByTestId("reboot-popover-button").click(); + } + + async clickRebootConfirm() { + await this.page.getByTestId("reboot-confirm-button").click(); + } + + async clickWakeUpButton() { + await this.page.getByTestId("wake-up-popover-button").click(); + } + + async clickWakeUpConfirm() { + await this.page.getByTestId("wake-up-confirm-button").click(); + } + + async clickShutdownButton() { + await this.page.getByTestId("shutdown-popover-button").click(); + } + + async clickShutdownConfirm() { + await this.page.getByTestId("shutdown-confirm-button").click(); + } + + async clickManagePowerButton() { + await this.page.getByTestId("manage-power-popover-button").click(); + } + + async clickMaxPowerOption() { + await this.page.getByTestId("power-option-maximize").locator("input").click(); + } + + async clickReducePowerOption() { + await this.page.getByTestId("power-option-reduce").locator("input").click(); + } + + async clickManagePowerConfirm() { + await this.clickIn("Confirm", "modal"); + } + + async clickEditMiningPoolButton() { + await this.page.getByTestId("mining-pool-popover-button").click(); + } + + async clickUpdateFirmwareButton() { + await this.page.getByTestId("firmware-update-popover-button").click(); + } + + async validateFirmwareUpdateModalOpened() { + await this.validateTitleInModal("Add firmware payload"); + } + + async selectExistingFirmwareFile(fileName: string) { + await this.page.getByRole("radio").filter({ hasText: fileName }).click(); + } + + async clickContinueInFirmwareUpdateModal() { + await this.clickIn("Continue", "modal"); + } + + async clickCoolingModeButton() { + await this.page.getByTestId("cooling-mode-popover-button").click(); + } + + async validateAirCooledOptionSelected() { + await expect(this.page.getByTestId("cooling-option-air").locator("input")).toBeChecked(); + } + + async clickAirCooledOption() { + await this.page.getByTestId("cooling-option-air").locator("input").click(); + } + + async clickImmersionCooledOption() { + await this.page.getByTestId("cooling-option-immersion").locator("input").click(); + } + + async clickUpdateCoolingModeConfirm() { + await this.page.getByRole("button", { name: "Update cooling mode" }).click(); + } + + async clickRenameButton() { + await this.page.getByTestId("rename-popover-button").click(); + } + + async clickAddToGroupButton() { + await this.page.getByTestId("add-to-group-popover-button").click(); + } + + async inputNewGroupName(groupName: string) { + await this.page.locator("#new-group-name").fill(groupName); + } + + async validateMinerGroupName(ipAddress: string, expectedGroupName: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await expect(minerRow.getByTestId("groups")).toContainText(expectedGroupName); + } + + async validateBulkRenamePageOpened() { + await this.validateTitle("Rename miners"); + } + + private bulkRenamePreviewContainer(): Locator { + return this.isMobile + ? this.page.getByTestId("bulk-rename-mobile-preview") + : this.page.getByTestId("bulk-rename-desktop-preview"); + } + + async validateBulkRenamePreviewContainsName(name: string) { + const container = this.bulkRenamePreviewContainer(); + await expect(container).toContainText(name); + } + + async getBulkRenamePreviewName(): Promise { + const container = this.bulkRenamePreviewContainer(); + await expect(container).toBeVisible(); + + const activeNewName = container.getByTestId("active-new-name").first(); + await expect(activeNewName).toBeVisible(); + return (await activeNewName.innerText()).trim(); + } + + async validateBulkRenamePreviewUnchangedPlaceholder() { + const container = this.bulkRenamePreviewContainer(); + await expect(container).toBeVisible(); + await expect(container.getByTestId("active-new-name")).toHaveCount(0); + await expect(container).toContainText("—"); + } + + async waitForBulkRenamePreviewName(expectedName: string) { + await expect + .poll(async () => await this.getBulkRenamePreviewName(), { + timeout: DEFAULT_TIMEOUT, + }) + .toBe(expectedName); + } + + async validateBulkRenamePreviewState(expectedName: string, currentName: string) { + if (expectedName === currentName) { + await this.validateBulkRenamePreviewUnchangedPlaceholder(); + return; + } + + await this.waitForBulkRenamePreviewName(expectedName); + } + + async clickBulkRenamePropertyToggle(propertyId: string) { + await this.page.getByTestId(`bulk-rename-row-${propertyId}`).locator('label:has(input[type="checkbox"])').click(); + } + + async getBulkRenamePropertyOrder(): Promise { + const rows = this.page.locator('[data-testid^="bulk-rename-row-"]'); + const count = await rows.count(); + const propertyIds: string[] = []; + + for (let i = 0; i < count; i++) { + const testId = await rows.nth(i).getAttribute("data-testid"); + if (testId) { + propertyIds.push(testId.replace("bulk-rename-row-", "")); + } + } + + return propertyIds; + } + + async setBulkRenamePropertyOrder(propertyIds: readonly string[]) { + const didPersist = await this.page.evaluate((orderedPropertyIds) => { + const storageKey = "proto-ui-preferences"; + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return false; + } + + const persisted = JSON.parse(raw); + const preferences = persisted?.state?.ui?.bulkRenamePreferences; + const properties = preferences?.properties; + if (!Array.isArray(properties)) { + return false; + } + + const propertyById = new Map(properties.map((property: { id: string }) => [property.id, property])); + const reorderedProperties = orderedPropertyIds + .map((propertyId) => propertyById.get(propertyId)) + .filter((property): property is { id: string } => Boolean(property)); + const remainingProperties = properties.filter( + (property: { id: string }) => !orderedPropertyIds.includes(property.id), + ); + + persisted.state.ui.bulkRenamePreferences = { + ...preferences, + properties: [...reorderedProperties, ...remainingProperties], + }; + + window.localStorage.setItem(storageKey, JSON.stringify(persisted)); + return true; + }, propertyIds); + + expect(didPersist, "Expected bulk rename preferences to be persisted in localStorage").toBe(true); + + await this.reloadPage(); + await this.waitForMinersTitle(); + await this.waitForMinersListToLoad(); + } + + async toggleBulkRenameProperty(propertyId: string, enabled: boolean) { + const row = this.page.getByTestId(`bulk-rename-row-${propertyId}`); + const checkbox = row.locator('label:has(input[type="checkbox"]) input[type="checkbox"]'); + await expect(checkbox).toHaveCount(1); + + const isChecked = await checkbox.isChecked(); + if (isChecked !== enabled) { + await this.clickBulkRenamePropertyToggle(propertyId); + if (enabled) { + await expect(checkbox).toBeChecked(); + } else { + await expect(checkbox).not.toBeChecked(); + } + } + } + + async clickBulkRenamePropertyOptions(propertyId: string) { + await this.page.getByTestId(`bulk-rename-options-${propertyId}`).click(); + } + + async dismissRenameOptionsModal() { + const modal = this.page.getByTestId("modal"); + + if (this.isMobile) { + const cancelButton = modal.getByRole("button", { name: "Cancel", exact: true }); + await expect(cancelButton).toBeVisible(); + await cancelButton.click(); + await this.validateModalIsClosed(); + return; + } + + const headerDismiss = modal.getByTestId("header-icon-button"); + const headerVisible = await headerDismiss.isVisible().catch(() => false); + if (headerVisible) { + await headerDismiss.click(); + await this.validateModalIsClosed(); + return; + } + + const cancelButton = modal.getByRole("button", { name: "Cancel", exact: true }); + await expect(cancelButton).toBeVisible(); + await cancelButton.click(); + await this.validateModalIsClosed(); + } + + async fillCustomPropertyPrefix(prefix: string) { + await this.page.getByTestId("custom-property-prefix-input").fill(prefix); + } + + async fillCustomPropertySuffix(suffix: string) { + await this.page.getByTestId("custom-property-suffix-input").fill(suffix); + } + + async fillCustomPropertyCounterStart(value: string | number) { + await this.page.getByTestId("custom-property-counter-start-input").fill(String(value)); + } + + async clickCustomPropertyCounterScale(counterScale: number) { + const counterScaleGroup = this.page.getByRole("radiogroup", { name: "Counter scale" }); + await expect(counterScaleGroup).toBeVisible(); + + const option = counterScaleGroup.getByTestId(`custom-property-counter-scale-option-${counterScale}`); + await option.click(); + await expect(option.locator('input[type="radio"]')).toBeChecked(); + } + + async clickCustomPropertyTypeButton() { + await this.page.getByTestId("custom-property-type-button").click(); + } + + async selectCustomPropertyType(typeId: string) { + await this.clickCustomPropertyTypeButton(); + await this.page.getByTestId(`custom-property-type-option-${typeId}`).click(); + } + + async fillCustomPropertyStringValue(value: string) { + await this.page.getByTestId("custom-property-string-input").fill(value); + } + + async validateCustomPropertyPreviewText(expectedText: string) { + await expect( + this.page.getByTestId("custom-property-preview"), + `Custom property preview should show "${expectedText}"`, + ).toHaveText(expectedText); + } + + async validateCustomPropertySaveDisabled() { + const desktopSave = this.page.getByTestId("custom-property-options-save-button"); + const mobileSave = this.page.getByTestId("custom-property-options-save-button-mobile"); + + const desktopVisible = await desktopSave.isVisible().catch(() => false); + const mobileVisible = await mobileSave.isVisible().catch(() => false); + + expect(desktopVisible || mobileVisible, "Expected at least one Save button to be visible").toBe(true); + + if (desktopVisible) { + await expect(desktopSave, "Desktop Save button should be disabled when counter start is empty").toBeDisabled(); + } + + if (mobileVisible) { + await expect(mobileSave, "Mobile Save button should be disabled when counter start is empty").toBeDisabled(); + } + } + + async clickFixedValueCharacterCountOption(option: number | "all") { + const optionId = typeof option === "number" ? String(option) : option; + const label = this.page.getByTestId(`fixed-value-character-count-option-${optionId}`); + await label.click(); + await expect(label.locator('input[type="radio"]')).toBeChecked(); + } + + async clickFixedValueStringSectionOption(section: "first" | "last") { + const label = this.page.getByTestId(`fixed-value-string-section-option-${section}`); + await label.click(); + await expect(label.locator('input[type="radio"]')).toBeChecked(); + } + + async validateFixedValuePreviewText(expectedText: string) { + if (expectedText === "") { + await expect(this.page.getByTestId("modal")).toContainText("—"); + return; + } + + await expect( + this.page.getByTestId("fixed-value-preview-highlighted"), + `Fixed value preview should show "${expectedText}"`, + ).toHaveText(expectedText); + } + + async getFixedValuePreviewText(): Promise { + const preview = this.page.getByTestId("fixed-value-preview-highlighted"); + const hasPreview = await preview.isVisible().catch(() => false); + if (hasPreview) { + return (await preview.innerText()).trim(); + } + + await expect(this.page.getByTestId("modal")).toContainText("—"); + return ""; + } + + async setCustomBulkRenameCounterScale(counterScale: number) { + await this.clickBulkRenamePropertyOptions("custom"); + + const counterStartInput = this.page.getByTestId("custom-property-counter-start-input"); + const isCounterStartVisible = await counterStartInput.isVisible(); + if (isCounterStartVisible) { + const currentValue = (await counterStartInput.inputValue()).trim(); + if (currentValue === "") { + await counterStartInput.fill("1"); + } + } + + const counterScaleGroup = this.page.getByRole("radiogroup", { name: "Counter scale" }); + await expect(counterScaleGroup).toBeVisible(); + const option = counterScaleGroup.getByTestId(`custom-property-counter-scale-option-${counterScale}`); + await option.click(); + await expect(option.locator('input[type="radio"]')).toBeChecked(); + + await this.clickIn("Save", "modal"); + await this.validateModalIsClosed(); + } + + async clickBulkRenameSave() { + await this.page.getByTestId("bulk-rename-save-button").filter({ visible: true }).click(); + } + + async selectBulkRenameSeparator(separatorId: string) { + const separator = this.page.getByTestId(`bulk-rename-separator-${separatorId}`); + const radio = separator.locator('input[type="radio"]'); + + if (await radio.isChecked()) { + return; + } + + await separator.locator("xpath=ancestor::label").click(); + await expect(radio).toBeChecked(); + } + + async confirmBulkRenameWarningsIfPresent() { + const duplicateNamesDialog = this.page.getByTestId("bulk-rename-duplicate-names-dialog"); + try { + await duplicateNamesDialog.waitFor({ state: "visible", timeout: DEFAULT_INTERVAL }); + await duplicateNamesDialog.getByRole("button", { name: "Yes, continue" }).click(); + } catch { + // Dialog not present, continue + } + + const noChangesDialog = this.page.getByTestId("bulk-rename-no-changes-dialog"); + try { + await noChangesDialog.waitFor({ state: "visible", timeout: DEFAULT_INTERVAL }); + await noChangesDialog.getByRole("button", { name: "Yes, continue" }).click(); + } catch { + // Dialog not present, continue + } + } + + async fillRenameInput(name: string) { + const input = this.page.getByTestId("rename-miner-input"); + await input.fill(name); + } + + async clickRenameSave() { + await this.clickSaveInModal(); + } + + async validateMinerName(ipAddress: string, expectedName: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await expect(minerRow.getByTestId("name")).toContainText(expectedName); + } + + async getMinerNameByIndex(index: number): Promise { + const rows = this.page.getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + await row.scrollIntoViewIfNeeded(); + return await row.getByTestId("name").innerText(); + } + + async getMinerNames(): Promise { + const nameElements = this.page.getByTestId("list-body").locator("tr").getByTestId("name"); + const names = await nameElements.allInnerTexts(); + return names.map((name) => name.trim()); + } + + async clickUnpairButton() { + await this.page.getByTestId("unpair-popover-button").click(); + } + + async clickUnpairConfirm() { + await this.page.getByTestId("unpair-confirm-button").click(); + } + + async validateUpdateInProgress() { + await expect(this.page.getByText(/Update in progress|updates in progress/)).toBeVisible(); + } + + async validateUpdateCompleted() { + await expect(this.page.getByText(/Update in progress|updates in progress/)).toBeHidden(); + } + + async waitForMinersListToLoad() { + const rows = this.page.getByTestId("list-body").locator("tr"); + await expect(rows).not.toHaveCount(0); + await expect(async () => { + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async validateAllMinersStatus(status: string, expected: boolean = true) { + await this.waitForColumnValuesToLoad("status"); + // To avoid miner actions hiding some valuable data in screenshots + await this.uncheckSelectAllCheckbox(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + // Start from last row to avoid extremely long tests due to lazy loading + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + const statusLocator = rows.nth(i).locator(`//td[@data-testid='status']`); + if (expected) { + await expect(statusLocator).toContainText(status, { + timeout: PROLONGED_TIMEOUT, + }); + } else { + await expect(statusLocator).not.toContainText(status, { + timeout: PROLONGED_TIMEOUT, + }); + } + } + } + + async validateNoMinerWithStatus(status: string) { + await this.validateAllMinersStatus(status, false); + } + + async validateAllMinersStatusSettled(status: string) { + await this.waitForColumnValuesToLoad("status"); + // To avoid miner actions hiding some valuable data in screenshots + await this.uncheckSelectAllCheckbox(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + // Start from last row to avoid extremely long tests due to lazy loading + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + const statusCell = rows.nth(i).locator(`//td[@data-testid='status']`); + const statusIndicator = statusCell.getByTestId("miner-status-indicator"); + + await expect(statusCell).toContainText(status, { + timeout: PROLONGED_TIMEOUT, + }); + await expect(statusIndicator).toHaveAttribute("data-status", /^(?!pending$).+/, { + timeout: PROLONGED_TIMEOUT, + }); + } + } + + async getMinerStatus(ipAddress: string): Promise { + const minerRow = await this.getMinerRowByIp(ipAddress); + return await minerRow.locator(`//td[@data-testid='status']`).innerText(); + } + + async getVisibleMinerStatuses(): Promise> { + await this.waitForColumnValuesToLoad("status"); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + const visibleMinerStatuses: Array<{ ipAddress: string; status: string }> = []; + + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + await row.scrollIntoViewIfNeeded(); + visibleMinerStatuses.push({ + ipAddress: (await row.getByTestId("ipAddress").innerText()).trim(), + status: (await row.getByTestId("status").innerText()).trim(), + }); + } + + return visibleMinerStatuses; + } + + async validateMinerStatus(ipAddress: string, expectedStatus: string) { + await expect(async () => { + try { + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + + await expect(statusCell).toHaveText(expectedStatus, { timeout: DEFAULT_INTERVAL }); + } catch (error) { + await this.reloadPage(); + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + + await expect(statusCell).toBeVisible(); + throw error; + } + }).toPass({ timeout: PROLONGED_TIMEOUT }); + } + + async validateMinerStatusSettled(ipAddress: string, expectedStatus: string, timeoutMs: number = PROLONGED_TIMEOUT) { + await expect(async () => { + try { + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + const statusIndicator = statusCell.getByTestId("miner-status-indicator"); + + await expect(statusCell).toHaveText(expectedStatus, { timeout: DEFAULT_INTERVAL }); + await expect(statusIndicator).toHaveAttribute("data-status", /^(?!pending$).+/, { + timeout: DEFAULT_INTERVAL, + }); + } catch (error) { + await this.reloadPage(); + const minerRow = await this.getMinerRowByIp(ipAddress); + const statusCell = minerRow.locator(`//td[@data-testid='status']`); + + await expect(statusCell).toBeVisible(); + throw error; + } + }).toPass({ timeout: timeoutMs }); + } + + async validateAllMinersIssues(issue: string, expected: boolean = true) { + await expect(async () => { + try { + // To make sure all miners are loaded and we are not missing any issues due to lazy loading + await this.waitForColumnValuesToLoad("status"); + // To avoid miner actions hiding some valuable data in screenshots + await this.uncheckSelectAllCheckbox(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + const issuesLocator = rows.nth(i).locator(`//td[@data-testid='issues']`); + + if (expected) { + await expect(issuesLocator).toContainText(issue, { + timeout: DEFAULT_INTERVAL, + }); + } else { + await expect(issuesLocator).not.toContainText(issue, { + timeout: DEFAULT_INTERVAL, + }); + } + } + } catch (error) { + await this.reloadPage(); + throw error; + } + }).toPass({ timeout: PROLONGED_TIMEOUT }); + } + + async validateNoMinerWithIssue(issue: string) { + await this.validateAllMinersIssues(issue, false); + } + + private async waitForColumnValuesToLoad(columnTestId: string) { + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + // Start from last row to avoid extremely long tests due to lazy loading + for (let i = rowCount - 1; i >= 0; i--) { + await rows.nth(i).scrollIntoViewIfNeeded(); + await expect(async () => { + const locator = rows.nth(i).locator(`//td[@data-testid='${columnTestId}']`); + await expect(locator).not.toHaveText("", { timeout: 5000 }); + await expect(locator).not.toHaveText("N/A", { timeout: 5000 }); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + } + + async waitForTemperaturesToLoad() { + await this.waitForColumnValuesToLoad("temperature"); + } + + private async validateTemperatureUnit(expectedUnit: string) { + await this.waitForTemperaturesToLoad(); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + for (let i = 0; i < rowCount; i++) { + const temperatureLocator = rows.nth(i).locator(`//td[@data-testid='temperature']`); + await temperatureLocator.scrollIntoViewIfNeeded(); + + // Get temperature text — format is "65.2 °F" or "65.2 °C" + const temperatureText = await temperatureLocator.innerText(); + const parts = temperatureText.split(" "); + expect(parts, `Expected temperature text to have value and unit, but got: "${temperatureText}"`).toHaveLength(2); + + // Validate unit - °C/°F + const unit = parts[1]; + expect(unit).toBe(expectedUnit); + + // Validate temperature value + const value = parseFloat(parts[0]); + if (expectedUnit === "°F") { + expect(value).toBeGreaterThanOrEqual(70.0); + } else { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(100.0); + } + } + } + + async validateTemperatureUnitFahrenheit() { + await this.validateTemperatureUnit("°F"); + } + + async validateTemperatureUnitCelsius() { + await this.validateTemperatureUnit("°C"); + } + + async validateActiveFilter(filterLabel: string) { + const activeFilterButton = this.page.locator(`[data-testid*="active-filter-"]`, { hasText: filterLabel }); + await expect(activeFilterButton).toBeVisible(); + } + + async validateActiveFilterNotVisible(filterLabel: string) { + const activeFilterButton = this.page.locator(`[data-testid*="active-filter-"]`, { hasText: filterLabel }); + await expect(activeFilterButton).toHaveCount(0); + } + + async clickClearAllFilters() { + await this.page.getByRole("button", { name: "Clear all filters", exact: true }).click(); + } + + async validateNoResultsEmptyState() { + await this.page.getByText("No results", { exact: true }).waitFor(); + await expect(this.page.getByText("No results", { exact: true })).toBeVisible(); + await expect(this.page.getByText("Try adjusting or clearing your filters.", { exact: true })).toBeVisible(); + await expect(this.page.getByRole("button", { name: "Clear all filters", exact: true })).toBeVisible(); + } + + async getMinersCount(): Promise { + const rows = this.page.getByTestId("list-body").locator("tr"); + return await rows.count(); + } + + async hasAnyMinerWithStatus(status: string): Promise { + await this.waitForColumnValuesToLoad("status"); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + + for (let i = 0; i < rowCount; i++) { + const statusText = (await rows.nth(i).getByTestId("status").innerText()).trim(); + if (statusText === status) { + return true; + } + } + + return false; + } + + async getMinerIpAddressByIndex(index: number): Promise { + const rows = this.page.getByTestId("list-body").locator("tr"); + const row = rows.nth(index); + return await row.getByTestId("ipAddress").innerText(); + } + + async getMinerIpAddressByStatus(status: string): Promise { + await this.waitForColumnValuesToLoad("status"); + const rows = this.page.getByTestId("list-body").locator("tr"); + const rowCount = await rows.count(); + const visibleStatuses: string[] = []; + + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + const statusText = (await row.getByTestId("status").innerText()).trim(); + if (statusText) { + visibleStatuses.push(statusText); + } + + if (statusText === status) { + return await row.getByTestId("ipAddress").innerText(); + } + } + + throw new Error( + `No visible miner with status "${status}". Visible statuses: ${visibleStatuses.join(", ") || "none"}`, + ); + } + + async getAuthenticatedMinerIpAddressByIndex(index: number): Promise { + // Filter out rows where the checkbox input is disabled (unauthenticated miners) + const allRows = this.page.getByTestId("list-body").locator("tr"); + const authenticatedRows = allRows.filter({ + has: this.page.locator('input[type="checkbox"]:not([disabled])'), + }); + + const authenticatedCount = await authenticatedRows.count(); + if (authenticatedCount <= index) { + throw new Error(`Only ${authenticatedCount} authenticated miners available, cannot get index ${index}`); + } + + const row = authenticatedRows.nth(index); + return await row.getByTestId("ipAddress").innerText(); + } + + async validateMinerNotPresent(ipAddress: string) { + const minerRow = this.page.getByTestId(`ipAddress`).getByText(ipAddress, { exact: true }); + await expect(minerRow).toBeHidden(); + } + + async clickAddMinersButton() { + await this.clickButton("Add miners"); + } + + async clickGetStarted() { + await this.clickButton("Get started"); + } + + async clickMinerElementByTestId(ipAddress: string, testId: string) { + const minerRow = await this.getMinerRowByIp(ipAddress); + await minerRow.getByTestId(testId).click(); + } + + /** + * Click a miner cell's interactive element and wait for the status modal to open. + * Targets the button inside the cell (not the td itself) to avoid clicking + * empty cell padding. Retries if the click doesn't open the modal. + */ + async clickMinerElementAndExpectModal(ipAddress: string, testId: string, minerName: string) { + const modalTitle = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()='${minerName} status']`, + ); + await expect(async () => { + const minerRow = await this.getMinerRowByIp(ipAddress); + const cell = minerRow.getByTestId(testId); + // Click the button inside the cell if one exists, otherwise the cell itself + const button = cell.locator("button").first(); + const target = (await button.isVisible().catch(() => false)) ? button : cell; + await target.click(); + await expect(modalTitle).toBeVisible({ timeout: 3000 }); + }).toPass({ timeout: DEFAULT_TIMEOUT }); + } + + async validateMinerIssuesModalOpened(minerName: string) { + await this.validateTitleInModal(`${minerName} status`); + } + + async validateErrorInModal(errorText: string, iconId: IssueIconId) { + const modal = this.page.locator('[role="dialog"], [data-testid*="modal"]'); + await expect(modal.getByText(errorText)).toBeVisible(); + await expect(modal.getByTestId(iconId)).toBeVisible(); + await expect(modal.getByText("Reported on 01/01/2026 at ").first()).toBeVisible(); + } + + async clickCloseStatusModal() { + await this.clickIn("Done", "modal"); + } +} diff --git a/client/e2eTests/protoFleet/pages/newPoolModal.ts b/client/e2eTests/protoFleet/pages/newPoolModal.ts new file mode 100644 index 000000000..e7b461014 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/newPoolModal.ts @@ -0,0 +1,44 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class NewPoolModalPage extends BasePage { + async validatePoolModalOpened() { + await expect(this.page.getByTestId("modal").getByText(`Default mining pool`).first()).toBeVisible(); + } + + async inputPoolName(name: string) { + await this.page.getByTestId(`pool-name-0-input`).fill(name); + } + + async inputPoolUrl(url: string) { + await this.page.getByTestId(`url-0-input`).fill(url); + } + + async inputPoolUsername(username: string) { + await this.page.getByTestId(`username-0-input`).fill(username); + } + + async clickTestConnection() { + await this.page.locator(`//button//*[text()='Test connection']`).click(); + } + + async validateConnectionFailed() { + await expect( + this.page.locator(`//div[@data-testid='pool-not-connected-callout' and not(contains(@class,'hidden'))]`), + ).toBeVisible(); + } + + async validateEmptyPoolUrlError() { + await this.validateTextIsVisible("A Pool URL is required to connect to this pool."); + } + + async validateConnectionSuccessful() { + await expect( + this.page.locator(`//div[@data-testid='pool-connected-callout' and not(contains(@class,'hidden'))]`), + ).toBeVisible(); + } + + async clickSaveNewPool() { + await this.page.getByTestId("pool-save-button").click(); + } +} diff --git a/client/e2eTests/protoFleet/pages/racks.ts b/client/e2eTests/protoFleet/pages/racks.ts new file mode 100644 index 000000000..612082f96 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/racks.ts @@ -0,0 +1,532 @@ +import { expect, type Locator } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; +import { ModalMinerSelectionList } from "./components/modalMinerSelectionList"; + +export interface RackSelectorMiner { + ipAddress: string; + sortName: string; +} + +export class RacksPage extends BasePage { + private readonly modalMinerList = new ModalMinerSelectionList(this.page.getByTestId("modal")); + + async validateRacksPageOpened() { + await this.validateTitle("Racks"); + } + + async clickAddRackButton() { + await this.clickButton("Add rack"); + await this.validateTitleInModal("Rack settings"); + } + + async inputZone(zone: string) { + await this.page.locator("#rack-zone").fill(zone); + } + + async inputRackLabel(label: string) { + await this.page.locator("#rack-label").fill(label); + } + + async getGeneratedRackLabel(): Promise { + return await this.page.locator("#rack-label").inputValue(); + } + + async enableCustomRackLayout() { + const columnsInput = this.page.locator("#rack-columns"); + if (!(await columnsInput.isDisabled())) { + return; + } + + await this.selectOption("rack-type-select", "New Layout"); + } + + async inputColumns(columns: number | string) { + await this.page.locator("#rack-columns").fill(String(columns)); + } + + async inputRows(rows: number | string) { + await this.page.locator("#rack-rows").fill(String(rows)); + } + + async getOrderIndexValue(): Promise { + const text = await this.page.getByTestId("order-index-select").innerText(); + return text + .replace(/\s+/g, " ") + .replace(/^Order index\s*/i, "") + .trim(); + } + + async clickContinueFromRackSettings() { + await this.clickIn("Continue", "modal"); + } + + async validateRackSettingsFieldError( + fieldId: "rack-zone" | "rack-label" | "rack-columns" | "rack-rows", + message: string, + ) { + await expect(this.page.locator(`#${fieldId}-error`)).toHaveText(message); + } + + async validateRackConfiguration(columns: number, rows: number, orderIndexValue: string) { + await expect(this.page.getByText(`${columns}x${rows}, ${orderIndexValue}`, { exact: true })).toBeVisible(); + } + + async validateAssignedMinersCount(assigned: number, total: number) { + await expect(this.page.getByText(`${assigned}/${total} assigned`, { exact: true })).toBeVisible(); + } + + async clickAddMiners() { + await this.clickButton("Add miners"); + await this.validateTitleInModal("Select miners"); + } + + async clickManageMiners() { + const overflowTrigger = this.page.getByTestId("overflow-menu-trigger"); + if (this.isMobile && (await overflowTrigger.isVisible().catch(() => false))) { + await overflowTrigger.click(); + } + + await this.clickButton("Manage Miners"); + await this.validateTitleInModal("Select miners"); + } + + async waitForMinerSelectorListToLoad() { + await this.modalMinerList.waitForListToLoad(); + } + + async getAllVisibleMinersFromSelector(): Promise { + const rowCount = await this.modalMinerList.getRowCount(); + const miners: RackSelectorMiner[] = []; + + for (let i = 0; i < rowCount; i++) { + miners.push({ + ipAddress: await this.modalMinerList.getCellTextByIndex(i, "ipAddress"), + sortName: await this.modalMinerList.getCellTextByIndex(i, "name"), + }); + } + + return miners; + } + + async getMinersFromSelector(indexes: number[]): Promise { + const miners: RackSelectorMiner[] = []; + + for (const index of indexes) { + miners.push({ + ipAddress: await this.modalMinerList.getCellTextByIndex(index, "ipAddress"), + sortName: await this.modalMinerList.getCellTextByIndex(index, "name"), + }); + } + + return miners; + } + + async getSelectableMinerIndexes(count: number): Promise { + const indexes = await this.modalMinerList.getSelectableRowIndexes(count); + expect(indexes).toHaveLength(count); + return indexes; + } + + async selectMinersInSelectorByIndex(indexes: number[]) { + await this.modalMinerList.selectRowsByIndex(indexes); + } + + async clickSelectAllMinersInSelector() { + await this.modalMinerList.clickSelectAllCheckbox(); + } + + async toggleMinerInSelectorByIpAddress(ipAddress: string) { + await this.modalMinerList.selectRowByCellText("ipAddress", ipAddress); + } + + async clickContinueInMinerSelector() { + await this.clickIn("Continue", "modal"); + } + + async validateMinerSelectorOverflowError(selectedCount: number, maxSlots: number) { + await this.validateTextInModal( + `Cannot add ${selectedCount} miners with only ${maxSlots} available slots. Deselect some miners or update your rack settings.`, + ); + } + + async clickAssignByName() { + await this.clickButton("Assign by name"); + } + + async clickAssignByNetwork() { + await this.clickButton("Assign by network"); + } + + async clickAssignManually() { + await this.clickButton("Assign manually"); + } + + async selectRackMiner(ipAddress: string) { + await this.clickMinerRow(ipAddress); + } + + async clickRackSlot(slotNumber: number) { + await this.getRackSlot(slotNumber).click(); + } + + async clickRackSlotMenuItem(menuItemLabel: "Search miners" | "Select from list") { + await this.page.getByRole("menuitem", { name: menuItemLabel, exact: true }).click(); + } + + async assignSearchMinerByIpAddress(ipAddress: string) { + await this.validateTitleInModal("Search miners"); + await this.modalMinerList.waitForListToLoad(); + await this.modalMinerList.selectRowByCellText("ipAddress", ipAddress); + await this.clickIn("Assign", "modal"); + await this.validateTitleInModalNotVisible("Search miners"); + } + + async validateMinersAssignedByName(miners: readonly RackSelectorMiner[]) { + const expectedPositions = this.getExpectedPositionsForAssignByName(miners); + + for (let i = 0; i < miners.length; i++) { + const minerRow = this.getAssignedMinerRow(miners[i].ipAddress); + await expect(minerRow.getByTestId("checkmark-icon")).toBeVisible(); + await expect(minerRow.getByTestId("rack-miner-position")).toHaveText( + `Position ${String(expectedPositions[i]).padStart(2, "0")}`, + ); + } + + await this.validateRackSlotsHighlighted(expectedPositions); + } + + async validateMinersAssignedByNetwork(miners: readonly RackSelectorMiner[]) { + const sortedMiners = this.getMinersSortedByIpAddress(miners); + + for (let i = 0; i < sortedMiners.length; i++) { + const row = this.getAssignedMinerRowByPosition(i + 1); + await expect(row.getByTestId("rack-miner-name")).toHaveText(sortedMiners[i].sortName); + await expect(row.getByTestId("rack-miner-subtitle")).toContainText(sortedMiners[i].ipAddress); + } + + await this.validateRackSlotsHighlighted(sortedMiners.map((_, index) => index + 1)); + } + + async assignMinersToSlotsInDomOrder(miners: readonly RackSelectorMiner[]) { + for (let i = 0; i < miners.length; i++) { + await this.clickMinerRow(miners[i].ipAddress); + await this.clickRackSlotByDomIndex(i); + } + } + + async validateRackSlotNumbersInDomOrder(expectedNumbers: readonly number[]) { + const expectedTexts = expectedNumbers.map((value) => String(value).padStart(2, "0")); + await expect(this.page.locator('[data-testid^="rack-slot-"] span.font-medium')).toHaveText(expectedTexts); + } + + async validateMinerPositions(miners: readonly RackSelectorMiner[], expectedPositions: readonly number[]) { + for (let i = 0; i < miners.length; i++) { + await this.validateMinerRowPosition(miners[i].ipAddress, expectedPositions[i]); + } + } + + async validateMinerRowHasGreenCheck(ipAddress: string) { + const minerRow = this.getAssignedMinerRow(ipAddress); + await expect(minerRow.getByTestId("checkmark-icon")).toBeVisible(); + } + + async validateMinerRowUnassigned(ipAddress: string) { + const minerRow = this.getAssignedMinerRow(ipAddress); + await expect(minerRow.getByTestId("checkmark-icon")).toHaveCount(0); + await expect(minerRow.getByTestId("rack-miner-position")).toHaveCount(0); + } + + async validateMinerRowPosition(ipAddress: string, position: number) { + const minerRow = this.getAssignedMinerRow(ipAddress); + await expect(minerRow).toContainText(`Position ${String(position).padStart(2, "0")}`); + } + + async validateRackSlotsHighlighted(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackSlot(slotNumber); + await expect(slot).toHaveAttribute("data-slot-state", "assigned"); + } + } + + async validateRackSlotsNotHighlighted(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackSlot(slotNumber); + await expect(slot).toHaveAttribute("data-slot-state", "empty"); + } + } + + async clickClearAssignments() { + await this.page.getByRole("button", { name: "Clear", exact: true }).click(); + } + + async clickSaveRack() { + await this.clickButton("Save"); + } + + async clickViewMiners() { + await this.clickButton("View miners"); + await expect(this.page).toHaveURL(/.*\/miners/); + } + + async clickEditRackSettings() { + const overflowTrigger = this.page.getByTestId("overflow-menu-trigger"); + if (this.isMobile && (await overflowTrigger.isVisible().catch(() => false))) { + await overflowTrigger.click(); + } + + await this.clickButton("Edit Rack Settings"); + await this.validateTitleInModal("Rack settings"); + } + + async changeOrderIndexAndContinue(orderIndexLabel: string) { + await this.selectOption("order-index-select", orderIndexLabel); + await this.clickContinueFromRackSettings(); + } + + async validateRackToast(label: string, action: "created" | "updated" = "created") { + await this.validateTextInToast(`Rack "${label}" ${action}`); + } + + async validateRackCardVisible(label: string, zone: string) { + await expect(this.getRackCard(label, zone)).toBeVisible(); + } + + async validateRackCardGrid(label: string, zone: string, columns: number, rows: number) { + const rackCard = this.getRackCard(label, zone); + const miniGridCells = rackCard.getByTestId("rack-card-grid").getByTestId("rack-card-slot"); + await expect(miniGridCells).toHaveCount(columns * rows); + } + + async openRackCard(label: string, zone: string) { + await this.getRackCard(label, zone).click(); + } + + async clickViewList() { + await this.clickButton("View list"); + } + + async clickViewGrid() { + await this.clickButton("View grid"); + } + + async applyZoneFilter(zoneNames: string[]) { + await this.clickVisibleFilterDropdown("Zone"); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + + await popover.getByRole("button", { name: "Reset", exact: true }).click(); + + for (const zoneName of zoneNames) { + await this.clickDropdownFilterOption(popover, zoneName); + } + + await popover.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(popover).toBeHidden(); + } + + async toggleAllZoneFilters() { + await this.clickVisibleFilterDropdown("Zone"); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await popover.getByText("Select all", { exact: true }).click(); + await popover.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(popover).toBeHidden(); + } + + async selectGridSort(sortLabel: string) { + await this.clickVisibleFilterDropdown("Sort"); + const popover = this.page.getByTestId("dropdown-filter-popover"); + await expect(popover).toBeVisible(); + await this.clickDropdownFilterOption(popover, sortLabel); + if (await popover.isVisible().catch(() => false)) { + await this.clickVisibleFilterDropdown("Sort"); + } + await expect(popover).toBeHidden(); + } + + async waitForRackListToLoad({ allowEmpty = true }: { allowEmpty?: boolean } = {}) { + await expect(this.page.getByRole("button", { name: "Add rack" }).first()).toBeVisible(); + + const rows = this.page.getByTestId("list-row"); + const noRowsText = this.page.getByText("You haven't set up any racks"); + + if (!allowEmpty) { + await expect(rows).not.toHaveCount(0); + } + + await expect(async () => { + const isEmptyStateVisible = await noRowsText.isVisible().catch(() => false); + if (isEmptyStateVisible) { + return; + } + + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async listRackNames(): Promise { + await this.waitForRackListToLoad(); + + const nameCells = this.page.getByTestId("list-row").getByTestId("name"); + const count = await nameCells.count(); + const names: string[] = []; + + for (let i = 0; i < count; i++) { + names.push((await nameCells.nth(i).innerText()).trim()); + } + + return names; + } + + async getGridRackLabels(): Promise { + const labels = this.page.locator('[data-testid="rack-card-label"]:visible'); + return (await labels.allTextContents()).map((label) => label.trim()).filter(Boolean); + } + + async validateRackRow(label: string, zone: string, miners: number) { + const row = this.getRackListRow(label); + await expect(row).toBeVisible(); + await expect(row.getByTestId("zone")).toHaveText(zone); + await expect(row.getByTestId("miners")).toHaveText(String(miners)); + } + + async openRackFromList(label: string) { + const row = this.getRackListRow(label); + await expect(row).toBeVisible(); + await row.getByTestId("name").getByRole("button", { name: label, exact: true }).click(); + } + + async clickEditRack() { + await this.clickButton("Edit rack"); + } + + async clickDeleteRack() { + const overflowTrigger = this.page.getByTestId("overflow-menu-trigger"); + if (this.isMobile && (await overflowTrigger.isVisible().catch(() => false))) { + await overflowTrigger.click(); + } + + await this.clickButton("Delete Rack"); + } + + async clickDeleteConfirm() { + await this.clickButton("Delete"); + } + + async validateRackDeletedToast() { + await this.validateTextInToast("Rack deleted"); + } + + async validateRackOverviewAssignedSlots(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackOverviewSlot(slotNumber); + await expect(slot).not.toHaveAttribute("data-slot-state", "empty"); + await expect(slot.getByTestId("rack-detail-slot-empty-action")).toHaveCount(0); + + const slotNumberLabel = slot.getByTestId("rack-detail-slot-number"); + if ((await slotNumberLabel.count()) > 0) { + await expect(slotNumberLabel).toHaveText(String(slotNumber).padStart(2, "0")); + } + } + } + + async validateRackOverviewEmptySlots(slotNumbers: readonly number[]) { + for (const slotNumber of slotNumbers) { + const slot = this.getRackOverviewSlot(slotNumber); + await expect(slot).toHaveAttribute("data-slot-state", "empty"); + await expect(slot.getByTestId("rack-detail-slot-empty-action")).toBeVisible(); + } + } + + async clickRackOverviewEmptySlot(slotNumber: number) { + await this.getRackOverviewSlot(slotNumber).getByTestId("rack-detail-slot-empty-action").click(); + await this.validateTitleInModal("Search miners"); + } + + private async selectOption(testId: string, optionLabel: string) { + await this.page.getByTestId(testId).click(); + await this.page.getByRole("option", { name: optionLabel, exact: true }).click(); + } + + private async clickDropdownFilterOption(popover: Locator, optionName: string) { + const optionByTestId = popover.getByTestId(`filter-option-${optionName}`).first(); + if (await optionByTestId.isVisible().catch(() => false)) { + await optionByTestId.click(); + return; + } + + await popover.getByText(optionName, { exact: true }).first().click(); + } + + private async clickVisibleFilterDropdown(title: string) { + const dropdowns = this.page.getByTestId(`filter-dropdown-${title}`); + const count = await dropdowns.count(); + + for (let i = 0; i < count; i++) { + const dropdown = dropdowns.nth(i); + if (await dropdown.isVisible().catch(() => false)) { + await dropdown.click(); + return; + } + } + + throw new Error(`No visible ${title} filter dropdown found`); + } + + private getAssignedMinerRow(ipAddress: string): Locator { + return this.page.getByTestId("rack-miner-row").filter({ hasText: ipAddress }).first(); + } + + private getAssignedMinerRowByPosition(position: number): Locator { + return this.page + .getByTestId("rack-miner-row") + .filter({ + has: this.page + .getByTestId("rack-miner-position") + .getByText(`Position ${String(position).padStart(2, "0")}`, { exact: true }), + }) + .first(); + } + + private async clickMinerRow(ipAddress: string) { + await this.getAssignedMinerRow(ipAddress).click(); + } + + private async clickRackSlotByDomIndex(index: number) { + await this.page.locator('[data-testid^="rack-slot-"]').nth(index).click(); + } + + private getRackSlot(slotNumber: number): Locator { + return this.page.getByTestId(new RegExp(`^rack-slot-0*${slotNumber}$`)); + } + + private getRackCard(label: string, zone: string): Locator { + return this.page.getByTestId("rack-card").filter({ hasText: label }).filter({ hasText: zone }).first(); + } + + private getRackOverviewSlot(slotNumber: number): Locator { + return this.page.getByTestId(`rack-detail-slot-${String(slotNumber).padStart(2, "0")}`); + } + + private getExpectedPositionsForAssignByName(miners: readonly RackSelectorMiner[]): number[] { + const sortedMiners = [...miners].sort((left, right) => left.sortName.localeCompare(right.sortName)); + return miners.map((miner) => sortedMiners.findIndex((candidate) => candidate.ipAddress === miner.ipAddress) + 1); + } + + private getMinersSortedByIpAddress(miners: readonly RackSelectorMiner[]): RackSelectorMiner[] { + const padIp = (ipAddress: string) => ipAddress.replace(/\d+/g, (octet) => octet.padStart(3, "0")); + return [...miners].sort((left, right) => padIp(left.ipAddress).localeCompare(padIp(right.ipAddress))); + } + + private getRackListRow(label: string): Locator { + return this.page + .getByTestId("list-row") + .filter({ has: this.page.getByTestId("name").getByRole("button", { name: label, exact: true }) }) + .first(); + } +} diff --git a/client/e2eTests/protoFleet/pages/settings.ts b/client/e2eTests/protoFleet/pages/settings.ts new file mode 100644 index 000000000..09c8c41e2 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settings.ts @@ -0,0 +1,36 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsPage extends BasePage { + async clickTemperatureButton() { + await this.page.locator('[data-testid="temperature-button"]').click(); + } + + async selectFahrenheit() { + await this.page.getByTestId("fahrenheit-option").click(); + } + + async selectCelsius() { + await this.page.getByTestId("celsius-option").click(); + } + + async clickDoneButton() { + await this.clickButton("Done"); + } + + async getCurrentTemperatureFormat(): Promise { + return await this.page.locator('[data-testid="temperature-button"]').innerText(); + } + + private async validateTemperatureFormat(format: string) { + await expect(this.page.locator('[data-testid="temperature-button"]')).toHaveText(format); + } + + async validateTemperatureFormatFahrenheit() { + await this.validateTemperatureFormat("Fahrenheit"); + } + + async validateTemperatureFormatCelsius() { + await this.validateTemperatureFormat("Celsius"); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsApiKeys.ts b/client/e2eTests/protoFleet/pages/settingsApiKeys.ts new file mode 100644 index 000000000..848155b1e --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsApiKeys.ts @@ -0,0 +1,155 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; + +export class SettingsApiKeysPage extends BasePage { + async waitForApiKeysListToLoad() { + const rows = this.page.getByTestId("list-body").getByTestId("list-row"); + const emptyState = this.page.getByText( + "No API keys yet. Create your first key to enable programmatic access to the Fleet API.", + ); + + await expect(this.page.getByText("Loading API keys...")).toBeHidden(); + await expect(this.page.getByRole("button", { name: "Create API key" })).toBeVisible(); + + await expect(async () => { + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + const rowCount = await rows.count(); + if (rowCount === 0) { + throw new Error("API keys list is still loading"); + } + + expect(rowCount).toBeGreaterThan(0); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async validateApiKeysPageOpened() { + await expect(this.page).toHaveURL(/.*\/settings\/api-keys/); + await this.validateTitle("API Keys"); + await this.validateButtonIsVisible("Create API key"); + } + + async clickCreateApiKey() { + await this.clickButton("Create API key"); + } + + async inputApiKeyName(name: string) { + await this.page.locator("#api-key-name").fill(name); + } + + async clickCreateInModal() { + await this.page.getByTestId("modal").getByRole("button", { name: "Create", exact: true }).click(); + } + + async validateApiKeyNameRequired() { + await this.validateTextInModal("Name is required"); + } + + async openExpirationDatePicker() { + const trigger = this.page.getByTestId("api-key-expires-trigger"); + + if ((await trigger.getAttribute("aria-expanded")) !== "true") { + await trigger.click(); + } + + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + } + + async validateExpirationDayDisabled(day: number) { + await expect(this.page.getByTestId(`api-key-expires-calendar-day-${day}`)).toBeDisabled(); + } + + async selectExpirationDate(date: Date) { + const today = new Date(); + const monthDelta = (date.getFullYear() - today.getFullYear()) * 12 + (date.getMonth() - today.getMonth()); + const calendar = this.page.getByTestId("api-key-expires-calendar"); + + await this.openExpirationDatePicker(); + await expect(calendar).toBeVisible(); + + for (let i = 0; i < Math.max(monthDelta, 0); i += 1) { + await this.page.getByTestId("api-key-expires-calendar-next-month").click(); + } + + for (let i = 0; i < Math.max(-monthDelta, 0); i += 1) { + await this.page.getByTestId("api-key-expires-calendar-prev-month").click(); + } + + await expect(calendar).toBeVisible(); + await this.page.getByTestId(`api-key-expires-calendar-day-${date.getDate()}`).click(); + } + + async validateApiKeyCreated() { + await expect(this.page.getByText("API key created")).toBeVisible(); + await expect(this.page.getByTestId("api-key-value")).not.toHaveText(""); + } + + async clickDone() { + await this.clickButton("Done"); + } + + async validateApiKeyVisible(name: string) { + await expect(this.getApiKeyRow(name)).toBeVisible(); + } + + async validateApiKeyHasNoExpiration(name: string) { + await expect(this.getApiKeyRow(name).getByTestId("expiresAt")).toHaveText("Never"); + } + + async validateApiKeyHasExpiration(name: string) { + await expect(this.getApiKeyRow(name).getByTestId("expiresAt")).not.toHaveText("Never"); + } + + async clickRevokeApiKey(name: string) { + await this.getApiKeyRow(name).getByRole("button", { name: "Revoke", exact: true }).click(); + } + + async confirmRevokeApiKey() { + await this.clickButton("Revoke key"); + } + + async validateApiKeyNotVisible(name: string) { + await expect(this.getApiKeyRow(name)).toHaveCount(0); + } + + async deleteApiKeysByPrefix(prefix: string) { + await this.waitForApiKeysListToLoad(); + + const rows = await this.page.getByTestId("list-body").getByTestId("list-row").all(); + const keyNames: string[] = []; + + for (const row of rows) { + const name = (await row.getByTestId("name").textContent())?.trim(); + if (name?.startsWith(prefix)) { + keyNames.push(name); + } + } + + for (const keyName of keyNames) { + await this.clickRevokeApiKey(keyName); + await this.confirmRevokeApiKey(); + await this.validateApiKeyNotVisible(keyName); + } + } + + private getApiKeyRow(name: string) { + return this.page + .getByTestId("list-body") + .getByTestId("list-row") + .filter({ + has: this.page.getByTestId("name").getByText(name, { exact: true }), + }); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsFirmware.ts b/client/e2eTests/protoFleet/pages/settingsFirmware.ts new file mode 100644 index 000000000..0dd692810 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsFirmware.ts @@ -0,0 +1,61 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; + +export class SettingsFirmwarePage extends BasePage { + async validateFirmwarePageOpened() { + await expect(this.page).toHaveURL(/.*\/settings\/firmware/); + await this.validateTitle("Firmware"); + } + + async clickUploadFirmware() { + await this.clickButton("Upload firmware"); + await this.validateTitleInModal("Upload firmware"); + } + + async uploadFirmwareFile(fileName: string, fileContents: string) { + await this.page.getByTestId("firmware-file-input").setInputFiles({ + name: fileName, + mimeType: "application/octet-stream", + buffer: Buffer.from(fileContents), + }); + } + + async clickDoneInUploadDialog() { + await this.clickIn("Done", "modal"); + } + + async validateFirmwareFileVisible(fileName: string) { + await expect(this.page.getByTestId("list-body").locator("tr").filter({ hasText: fileName })).toBeVisible(); + } + + async deleteAllFirmwareFilesIfAny() { + const emptyState = this.page.getByText("No firmware files uploaded.", { exact: true }); + const firmwareRows = this.page.getByTestId("list-body").locator("tr"); + const loadingState = this.page.getByText("Loading firmware files...", { exact: true }); + const deleteAllButton = this.page.getByRole("button", { name: "Delete all", exact: true }).first(); + + if (await loadingState.isVisible().catch(() => false)) { + await expect(loadingState).toBeHidden(); + } + + await expect(async () => { + const emptyVisible = await emptyState.isVisible().catch(() => false); + const hasRows = (await firmwareRows.count()) > 0; + + expect(emptyVisible || hasRows).toBeTruthy(); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + await expect(deleteAllButton).toBeEnabled(); + await deleteAllButton.click(); + const deleteAllDialog = this.page.getByTestId("delete-all-firmware-dialog"); + await deleteAllDialog.getByRole("button", { name: "Delete all" }).click(); + await expect(deleteAllDialog).toBeHidden(); + await expect(deleteAllButton).toBeDisabled(); + await expect(emptyState).toBeVisible(); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsPools.ts b/client/e2eTests/protoFleet/pages/settingsPools.ts new file mode 100644 index 000000000..e439d5e77 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsPools.ts @@ -0,0 +1,35 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsPoolsPage extends BasePage { + async validateMiningPoolsPageOpened() { + await expect(this.page).toHaveURL(/.*\/mining-pools/); + await this.validateButtonIsVisible("Add pool"); + } + + async clickAddPool() { + await this.clickButton("Add pool"); + } + + async validatePoolEntryByUniqueName(expectedName: string, expectedUrl: string, expectedUsername: string) { + await expect(this.page.getByTestId(`pool-row`).getByTestId("pool-name").getByText(expectedName)).toBeVisible(); + const row = this.page + .getByTestId(`pool-row`) + .filter({ has: this.page.getByTestId("pool-name").getByText(expectedName) }); + await expect(row.getByTestId("pool-url").getByText(expectedUrl)).toBeVisible(); + await expect(row.getByTestId("pool-username").getByText(expectedUsername)).toBeVisible(); + } + + async deleteAllPools() { + const poolRows = this.page.getByTestId("pool-row"); + const poolCount = await poolRows.count(); + + for (let i = 0; i < poolCount; i++) { + const firstRow = poolRows.first(); + await firstRow.getByRole("button", { name: "Options menu", exact: true }).click(); + await this.clickButton("Delete pool"); + await expect(poolRows).toHaveCount(poolCount - 1 - i); + } + await expect(poolRows).toHaveCount(0); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsSchedules.ts b/client/e2eTests/protoFleet/pages/settingsSchedules.ts new file mode 100644 index 000000000..e6ae9981e --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsSchedules.ts @@ -0,0 +1,224 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; +import { ModalMinerSelectionList } from "./components/modalMinerSelectionList"; + +export class SettingsSchedulesPage extends BasePage { + private readonly modalMinerList = new ModalMinerSelectionList(this.page.getByTestId("modal")); + + async validateSchedulesPageOpened() { + await expect(this.page).toHaveURL(/.*\/settings\/schedules/); + await this.validateTitle("Schedules"); + await this.validateButtonIsVisible("Add a schedule"); + } + + async clickAddSchedule() { + await this.clickButton("Add a schedule"); + await this.validateTitle("Add a schedule"); + } + + async inputScheduleName(name: string) { + await this.page.locator("#schedule-name").fill(name); + } + + async selectActionType(label: string) { + await this.selectOption("#schedule-action", "Action type", label); + } + + async selectScheduleType(label: string) { + await this.selectOption("#schedule-type", "Type", label); + } + + async selectScheduleFrequency(label: string) { + await this.selectOption("#schedule-frequency", "Frequency", label); + } + + async validateSaveDisabled() { + await expect(this.page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + } + + async validateSaveEnabled() { + await expect(this.page.getByRole("button", { name: "Save", exact: true })).toBeEnabled(); + } + + async inputDayOfMonth(value: string) { + const input = this.page.locator("#schedule-day-of-month"); + await input.fill(value); + await input.blur(); + } + + async validateValidationMessage(text: string) { + await expect(this.page.getByText(text, { exact: true })).toBeVisible(); + } + + async openWeekdaySelect() { + await this.page.locator("#schedule-days-of-week").click(); + } + + async selectWeekday(label: string) { + await this.openWeekdaySelect(); + await this.page.getByRole("option", { name: label, exact: true }).click(); + await this.page.locator("#schedule-days-of-week").click(); + await expect(this.page.getByRole("listbox", { name: "Days options" })).toBeHidden(); + } + + async selectStartDate(daysFromToday: number) { + const today = new Date(); + const target = new Date(); + target.setDate(target.getDate() + daysFromToday); + const monthDelta = (target.getFullYear() - today.getFullYear()) * 12 + (target.getMonth() - today.getMonth()); + + await this.page.getByTestId("schedule-start-date-trigger").click(); + await expect(this.page.getByTestId("schedule-start-date-calendar")).toBeVisible(); + + for (let i = 0; i < Math.max(monthDelta, 0); i += 1) { + await this.page.getByTestId("schedule-start-date-calendar-next-month").click(); + } + + for (let i = 0; i < Math.max(-monthDelta, 0); i += 1) { + await this.page.getByTestId("schedule-start-date-calendar-prev-month").click(); + } + + await this.page.getByTestId(`schedule-start-date-calendar-day-${target.getDate()}`).click(); + } + + async openMinersTargetSelector() { + await this.page + .locator("button") + .filter({ has: this.page.getByText("Miners", { exact: true }) }) + .first() + .click(); + await this.validateTitleInModal("Select miners"); + } + + async waitForMinerSelectionModalToLoad() { + await this.modalMinerList.waitForListToLoad(); + } + + async selectFirstMiners(count: number) { + const indexes = await this.modalMinerList.getSelectableRowIndexes(count); + if (indexes.length < count) { + throw new Error(`Expected at least ${count} selectable miners, found ${indexes.length}`); + } + + await this.modalMinerList.selectRowsByIndex(indexes); + } + + async confirmMinerSelection() { + await this.page.getByTestId("modal").getByRole("button", { name: "Done", exact: true }).click(); + await expect(this.page.getByTestId("modal")).toBeHidden(); + } + + async clickSaveSchedule() { + await this.clickButton("Save"); + } + + async validateScheduleVisible(name: string) { + await expect(this.getScheduleRow(name)).toBeVisible(); + } + + async validateScheduleNotVisible(name: string) { + await expect(this.getScheduleRows(name)).toHaveCount(0); + } + + async validateScheduleStatus(name: string, expectedStatus: string) { + await expect(this.getScheduleRow(name).getByTestId("status")).toContainText(expectedStatus); + } + + async validateScheduleAction(name: string, expectedAction: string) { + await expect(this.getScheduleRow(name).getByTestId("action").first()).toHaveText(expectedAction); + } + + async validateScheduleSummary(name: string, expectedSummary: string) { + await expect(this.getScheduleRow(name).getByTestId("schedule")).toContainText(expectedSummary); + } + + async validateScheduleTargetSummary(name: string, expectedSummary: string) { + await expect(this.getScheduleRow(name).getByTestId("name")).toContainText(expectedSummary); + } + + async openScheduleActions(name: string) { + const row = this.getScheduleRow(name); + await expect(row).toBeVisible(); + await row.getByTestId("list-actions-trigger").click(); + } + + async clickScheduleAction(actionName: string) { + await this.page.getByText(actionName, { exact: true }).click(); + } + + async openEditSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Edit"); + await this.validateTitle("Edit schedule"); + } + + async pauseSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Pause"); + } + + async resumeSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Resume"); + } + + async deleteSchedule(name: string) { + await this.openScheduleActions(name); + await this.clickScheduleAction("Delete"); + await this.validateScheduleNotVisible(name); + } + + async waitForSchedulesListToLoad() { + const rows = this.page.getByTestId("list-row"); + const emptyState = this.page.getByText("Configure schedules to automate actions for your miners."); + + await expect(this.page.getByRole("button", { name: "Add a schedule" })).toBeVisible(); + + if (await emptyState.isVisible().catch(() => false)) { + return; + } + + await expect(async () => { + const rowCount = await rows.count(); + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + const rowCountAfterDelay = await rows.count(); + // eslint-disable-next-line playwright/prefer-to-have-count -- intentionally non-retrying: verifies count has stabilized + expect(rowCountAfterDelay).toBe(rowCount); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } + + async deleteSchedulesByPrefix(prefix: string) { + await this.waitForSchedulesListToLoad(); + + const rows = await this.page.getByTestId("list-row").all(); + const scheduleNames: string[] = []; + + for (const row of rows) { + const name = (await row.getByTestId("name").locator("span").first().textContent())?.trim(); + if (name?.startsWith(prefix)) { + scheduleNames.push(name); + } + } + + for (const scheduleName of scheduleNames) { + await this.deleteSchedule(scheduleName); + } + } + + private getScheduleRow(name: string) { + return this.getScheduleRows(name).first(); + } + + private getScheduleRows(name: string) { + return this.page.getByTestId("list-row").filter({ + has: this.page.getByTestId("name").getByText(name, { exact: true }), + }); + } + + private async selectOption(triggerSelector: string, label: string, optionLabel: string) { + await this.page.locator(triggerSelector).click(); + await this.page.getByRole("option", { name: optionLabel, exact: true }).click(); + await expect(this.page.getByRole("button", { name: label, exact: true })).toContainText(optionLabel); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsSecurity.ts b/client/e2eTests/protoFleet/pages/settingsSecurity.ts new file mode 100644 index 000000000..f6d53192d --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsSecurity.ts @@ -0,0 +1,52 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsSecurityPage extends BasePage { + async clickUpdateUsername() { + await this.clickIn("Update", "username-row"); + } + + async clickUpdatePassword() { + await this.clickIn("Update", "password-row"); + } + + async inputCurrentPassword(password: string) { + await this.page.locator(`//input[@id='currentPassword']`).fill(password); + } + + async clickConfirm() { + await this.clickIn("Confirm", "modal"); + } + + async inputNewUsername(username: string) { + await this.page.locator(`//input[@id='newUsername']`).fill(username); + } + + async clickConfirmUsername() { + await this.clickIn("Confirm", "modal"); + } + + async validateUsernameChangeToast() { + await expect(this.page.getByText(`Username updated`)).toBeVisible(); + } + + async validateUsername(username: string) { + await expect(this.page.getByTestId("username-value")).toHaveText(username); + } + + async inputNewPassword(password: string) { + await this.page.locator(`//input[@id='newPassword']`).fill(password); + } + + async inputConfirmPassword(password: string) { + await this.page.locator(`//input[@id='confirmPassword']`).fill(password); + } + + async clickConfirmPassword() { + await this.clickIn("Confirm", "modal"); + } + + async validatePasswordChangeToast() { + await expect(this.page.getByText(`Password updated`)).toBeVisible(); + } +} diff --git a/client/e2eTests/protoFleet/pages/settingsTeam.ts b/client/e2eTests/protoFleet/pages/settingsTeam.ts new file mode 100644 index 000000000..34b58f262 --- /dev/null +++ b/client/e2eTests/protoFleet/pages/settingsTeam.ts @@ -0,0 +1,110 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class SettingsTeamPage extends BasePage { + async validateTeamSettingsPageOpened() { + await expect(this.page).toHaveURL(/.*\/team/); + await this.validateTitle("Team"); + } + + async validateIsAdmin() { + await expect(this.page.getByRole("button", { name: "Add team member" })).toBeVisible(); + } + + async clickAddTeamMember() { + await this.clickButton("Add team member"); + } + + async inputMemberUsername(username: string) { + await this.page.locator(`//input[@id='username']`).fill(username); + } + + async clickSaveTeamMember() { + await this.clickButton("Save"); + } + + async validateMemberAdded() { + await expect(this.page.getByTestId("modal").getByText("Member added")).toBeVisible(); + } + + async validateCopyPasswordButtonVisible() { + await expect(this.page.locator(`//button[@aria-label="Copy password"]`)).toBeVisible(); + } + + async clickDone() { + await this.clickButton("Done"); + } + + async validateMemberRole(username: string, role: string) { + const memberRow = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ + has: this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`), + }); + await expect(memberRow.locator(`//td[@data-testid='role']`)).toHaveText(role); + } + + async validateMemberLastLogin(username: string, lastLogin: string) { + const memberRow = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ + has: this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`), + }); + await expect(memberRow.locator(`//td[@data-testid='lastLoginAt']`)).toHaveText(lastLogin); + } + + async getTemporaryPassword(): Promise { + return await this.page.getByTestId("temporary-password").innerText(); + } + + async validateMemberVisible(username: string) { + await expect(this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`)).toBeVisible(); + } + + async validateNoAdminRights() { + await expect(this.page.getByRole("button", { name: "Add team member" })).toBeHidden(); + } + + async clickMemberActionsMenu(username: string) { + const memberRow = this.page + .getByTestId("list-body") + .locator("tr") + .filter({ + has: this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`), + }); + await memberRow.getByTestId("list-actions-trigger").click(); + } + + async clickResetPassword() { + await this.clickButton("Reset Password"); + } + + async clickResetMemberPasswordConfirm() { + await this.clickButton("Reset member password"); + } + + async validatePasswordReset() { + await expect(this.page.getByTestId("temporary-password")).toBeVisible(); + await expect(this.page.getByRole("button", { name: "Done", exact: true })).toBeVisible(); + } + + async clickDeactivate() { + await this.clickButton("Deactivate"); + } + + async clickConfirmDeactivation() { + await this.clickButton("Confirm deactivation"); + } + + async validateMemberDeactivatedMessage(username: string) { + await expect( + this.page.locator(`//*[contains(@class,'heading')][contains(text(),'${username} has been deactivated')]`), + ).toBeVisible(); + } + + async validateMemberNotInList(username: string) { + await expect(this.page.locator(`//td[@data-testid='username']//*[text()='${username}']`)).toBeHidden(); + } +} diff --git a/client/e2eTests/protoFleet/playwright.config.ts b/client/e2eTests/protoFleet/playwright.config.ts new file mode 100644 index 000000000..e4c70a4c0 --- /dev/null +++ b/client/e2eTests/protoFleet/playwright.config.ts @@ -0,0 +1,71 @@ +import { defineConfig } from "@playwright/test"; +import { testConfig } from "./config/test.config"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ + +export default defineConfig({ + testDir: "./spec", + /* Run tests in serial order (one at a time) */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI for more stability */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [ + ["html", { outputFolder: "playwright-report", open: "never" }], + ["github"], + ["junit", { outputFile: "test-results/results.xml" }], + ] + : "html", + /* Global timeout for each test */ + timeout: testConfig.testTimeout, + /* Set default timeout for all expect() assertions */ + expect: { + timeout: testConfig.actionTimeout, + }, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: testConfig.baseUrl, + + /* Set a consistent viewport size for all tests */ + viewport: { width: 1600, height: 900 }, + + /* Set default timeout for actions like click, fill, etc. */ + actionTimeout: testConfig.actionTimeout, + + /* Capture screenshots (only on failure) and video (retain on failure) so they appear in the HTML report */ + screenshot: "only-on-failure", + video: "retain-on-failure", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + // E.g.: npx playwright test --project=desktop + projects: [ + { + name: "desktop", + testMatch: /.*\.spec\.ts/, + use: { + viewport: { width: 1600, height: 900 }, + isMobile: false, + }, + }, + // Resolution of the iPhone 14 Pro / 15 Pro / 16 + { + name: "mobile", + testMatch: /.*\.spec\.ts/, + use: { + viewport: { width: 393, height: 852 }, + isMobile: true, + }, + }, + ], +}); diff --git a/client/e2eTests/protoFleet/spec/00-onboarding.spec.ts b/client/e2eTests/protoFleet/spec/00-onboarding.spec.ts new file mode 100644 index 000000000..2c68cb475 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/00-onboarding.spec.ts @@ -0,0 +1,184 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { expect, test } from "../fixtures/pageFixtures"; + +test.describe("Proto Fleet - Onboarding", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Onboard the admin user @setup", async ({ authPage }) => { + await test.step("Create credentials", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.clickContinue(); + }); + + await test.step("Validate admin is logged in", async () => { + await authPage.validateLoggedIn(); + }); + }); + + test("Validate null states", async ({ homePage, commonSteps, minersPage, groupsPage, settingsPoolsPage }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Validate Home screen null state due to no miners added", async () => { + await homePage.validateTextIsVisible("Let's setup your fleet."); + await homePage.validateTextIsVisible("Add miners to your fleet to get started."); + await homePage.validateButtonIsVisible("Get Started"); + }); + + await test.step("Validate Miners screen null state due to no miners added", async () => { + await homePage.navigateToMinersPage(); + await minersPage.validateTextIsVisible("You haven't paired any miners"); + await minersPage.validateTextIsVisible("Add miners to your fleet to get started."); + await minersPage.validateButtonIsVisible("Get Started"); + }); + + await test.step("Validate Groups screen null state due to no groups added", async () => { + await minersPage.navigateToGroupsPage(); + await groupsPage.validateTextIsVisible("Organize your miners into groups."); + await groupsPage.validateButtonIsVisible("Add group"); + }); + + await test.step("Validate Pools screen null state due to no pools added", async () => { + await groupsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateTitle("Pools"); + await settingsPoolsPage.validateTextIsVisible("Add a pool to start assigning your miners."); + await settingsPoolsPage.validateButtonIsVisible("Add pool"); + }); + }); + + if (testConfig.target === "real") { + test("Add specific miners @setup", async ({ authPage, minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Get started with onboarding", async () => { + await authPage.clickGetStarted(); + }); + + const rawMinerIps = process.env.E2E_MINER_IPS || ""; + const minerIps = rawMinerIps + .split(",") + .map((ip) => ip.trim()) + .filter(Boolean); + expect( + minerIps, + "E2E_MINER_IPS must be a comma-separated list of miner IPs, e.g. '192.168.1.10,192.168.1.11'.", + ).not.toHaveLength(0); + + const listOfMiners = minerIps.join(","); + console.warn("Running onboarding test with the following miner IPs:", minerIps); + const amountOfMiners = minerIps.length; + + await test.step("Find and add miners", async () => { + await addMinersPage.inputMinerIp(listOfMiners); + await addMinersPage.clickFindMinersByIp(); + await addMinersPage.clickContinueWithXMiners(amountOfMiners); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Validate miners added", async () => { + await minersPage.validateMinersAdded(amountOfMiners); + }); + }); + } else { + test("Add all scanned miners @setup", async ({ authPage, minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Get started with onboarding", async () => { + await authPage.clickGetStarted(); + }); + + await test.step("Find and add miners", async () => { + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickContinueWithSelectedMiners(); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Validate miners added", async () => { + await minersPage.validateMinersAdded(); + }); + }); + } + + if (testConfig.target !== "real") { + test("Authenticate miners @setup", async ({ homePage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Start authentication process", async () => { + await homePage.validateCompleteSetupTitle(); + await homePage.clickAuthenticateMinersButton(); + await homePage.validateAuthenticateMinersModalTitle(); + }); + + await test.step("Validate 4 miners need authentication - S17, S19, S19, S21", async () => { + await homePage.validateTextInModal("Bulk authenticate"); + await homePage.validateTextInModal("4 miners remaining"); + await homePage.clickShowMinersButton(); + await homePage.validateTextInModal("Bulk authenticate"); + await homePage.validateTextInModal("4 miners remaining"); + const miners = await homePage.getListOfMinersToAuthenticate(); + expect(miners).toHaveLength(4); + expect(miners).toContain("Antminer S21 XP"); + expect(miners).toContain("Antminer S17 XP"); + expect(miners.filter((model) => model === "Antminer S19 XP")).toHaveLength(2); + }); + + await test.step("Bulk authenticate all miners with S19 credentials", async () => { + await homePage.inputMinerAuthUsername("root19"); + await homePage.inputMinerAuthPassword("root19"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate S19 miners authenticated, but S21 and S17 not", async () => { + await homePage.validateTextInToast("You authenticated 2 of 4 miners."); + await homePage.validateCalloutInModal("Try your username and password again."); + await homePage.clickCalloutButton(); + const miners = await homePage.getListOfMinersToAuthenticate(); + expect(miners).toHaveLength(2); + expect(miners).toContain("Antminer S21 XP"); + expect(miners).toContain("Antminer S17 XP"); + }); + + await test.step("Try authenticating S21 miner incorrectly with S17 miner's credentials", async () => { + await homePage.clickMinerAuthCheckbox("Antminer S17 XP"); + await homePage.inputMinerRowUsername("Antminer S21 XP", "root17"); + await homePage.inputMinerRowPassword("Antminer S21 XP", "root17"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate S21 miner's authentication failed", async () => { + await homePage.validateTextInToast("Authentication failed. Please check your credentials and try again."); + await homePage.validateCalloutInModal("Try your username and password again."); + await homePage.clickCalloutButton(); + }); + + await test.step("Authenticating S21 miner", async () => { + await homePage.inputMinerRowUsername("Antminer S21 XP", "root21"); + await homePage.inputMinerRowPassword("Antminer S21 XP", "root21"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate S21 miner successfully authenticated", async () => { + await homePage.validateTextInToast("1 miner authenticated."); + await homePage.validateNoCalloutInModal(); + }); + + await test.step("Bulk authenticate last miner - S17", async () => { + await homePage.clickMinerAuthCheckbox("Antminer S17 XP"); + await homePage.inputMinerAuthUsername("root17"); + await homePage.inputMinerAuthPassword("root17"); + await homePage.clickAuthenticateMinersConfirmButton(); + }); + + await test.step("Validate all miners authenticated", async () => { + await homePage.validateTextInToast("All miners authenticated."); + await homePage.validateModalClosed(); + await homePage.validateAuthenticateMinersButtonNotVisible(); + }); + }); + } +}); diff --git a/client/e2eTests/protoFleet/spec/01-miningPools.spec.ts b/client/e2eTests/protoFleet/spec/01-miningPools.spec.ts new file mode 100644 index 000000000..1e6ce2a88 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/01-miningPools.spec.ts @@ -0,0 +1,286 @@ +/* eslint-disable playwright/expect-expect */ +import { DEFAULT_INTERVAL, testConfig } from "../config/test.config"; +import { expect, test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { LoginModalComponent } from "../pages/components/loginModal"; +import { EditPoolPage } from "../pages/editPool"; +import { MinersPage } from "../pages/miners"; +import { NewPoolModalPage } from "../pages/newPoolModal"; +import { SettingsPage } from "../pages/settings"; +import { SettingsPoolsPage } from "../pages/settingsPools"; + +function generatePoolUsername(): string { + return generateRandomText("PoolUsername"); +} + +test.describe("Mining Pools", () => { + test.beforeEach(async ({ page, settingsPage, settingsPoolsPage, commonSteps }) => { + await page.goto("/"); + + // Clear all existing pools to ensure consistent test state + await commonSteps.loginAsAdmin(); + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + await settingsPoolsPage.deleteAllPools(); + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Add default pool to miners", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const editPoolPage = new EditPoolPage(page, isMobile); + const newPoolModal = new NewPoolModalPage(page, isMobile); + const loginModal = new LoginModalComponent(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const settingsPoolsPage = new SettingsPoolsPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + await commonSteps.goToMinersPage(); + + const amountOfMiners = await minersPage.getMinersCount(); + if (amountOfMiners > 0) { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + + await editPoolPage.clickAddPoolButton(); + await editPoolPage.clickAddNewPool(); + await newPoolModal.inputPoolName("PoolNameDefault"); + await newPoolModal.inputPoolUrl(validPoolUrl); + + await newPoolModal.inputPoolUsername(generateRandomText("Afterhook")); + // await newPoolModal.inputPoolUsername(validUsername); // use when DASH-1407 is fixed + await newPoolModal.clickSaveNewPool(); + await editPoolPage.clickAssignToXMiners(amountOfMiners); + await editPoolPage.validateTextInToastGroup("Assigned pools"); + } + + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + + const poolRows = page.getByTestId("pool-row"); + const poolCount = await poolRows.count(); + + for (let i = poolCount - 1; i >= 0; i--) { + const row = poolRows.nth(i); + const poolNameElement = row.getByTestId("pool-name"); + const poolName = await poolNameElement.textContent(); + + if (poolName && poolName.startsWith("PoolName")) { + await row.getByRole("button", { name: "Options menu", exact: true }).click(); + await settingsPoolsPage.clickButton("Delete pool"); + } + } + } finally { + await context.close(); + } + }); + + const invalidPoolUrl = "stratum+tcp://eu1.examplepool.com:3333"; + const validPoolUrl = "stratum+tcp://mine.ocean.xyz:3334"; + // When DASH-1407 is fixed, use a real wallet, so that real miners always have it configured + // Also, removed the actual username for security reasons. Need to get from GH secrets + // const validUsername = "aaaaaaa"; + + test("Configure mining pool", async ({ settingsPage, settingsPoolsPage, newPoolModal }) => { + const settingsPoolName = generateRandomText("PoolName"); + const poolUsername = generatePoolUsername(); + + await test.step("Navigate to mining pools settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + }); + + await test.step("Start adding a pool", async () => { + await settingsPoolsPage.clickAddPool(); + await newPoolModal.validatePoolModalOpened(); + }); + + await test.step("Validate empty pool url message", async () => { + await newPoolModal.clickTestConnection(); + await newPoolModal.validateEmptyPoolUrlError(); + }); + + await test.step("Configure mining pool with invalid URL", async () => { + await newPoolModal.inputPoolName(settingsPoolName); + await newPoolModal.inputPoolUrl(invalidPoolUrl); + await newPoolModal.inputPoolUsername(poolUsername); + }); + + await test.step("Test connection - expect failure", async () => { + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionFailed(); + }); + + await test.step("Change URL to a valid one", async () => { + await newPoolModal.inputPoolUrl(validPoolUrl); + }); + + await test.step("Test connection - expect success", async () => { + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionSuccessful(); + }); + + await test.step("Save and validate pool URL", async () => { + await newPoolModal.clickSaveNewPool(); + await settingsPoolsPage.validatePoolEntryByUniqueName(settingsPoolName, validPoolUrl, poolUsername); + }); + }); + + test("Add default mining pool to all miners @setup", async ({ + minersPage, + editPoolPage, + newPoolModal, + loginModal, + commonSteps, + }) => { + const poolName = generateRandomText("PoolName"); + await commonSteps.goToMinersPage(); + + let amountOfMiners: number; + + await test.step("Select all miners and open pool editor", async () => { + amountOfMiners = await minersPage.getMinersCount(); + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + }); + + await test.step("Add default mining pool", async () => { + await editPoolPage.clickAddPoolButton(); + await editPoolPage.clickAddNewPool(); + await editPoolPage.validateModalIsOpen(); + await newPoolModal.inputPoolName(poolName); + await newPoolModal.inputPoolUrl(validPoolUrl); + await newPoolModal.inputPoolUsername(generateRandomText("allMinerDefault")); + // await newPoolModal.inputPoolUsername(validUsername); // use when DASH-1407 is fixed + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionSuccessful(); + await newPoolModal.clickSaveNewPool(); + await editPoolPage.validateModalIsClosed(); + await editPoolPage.validatePoolByIndex(0, poolName, validPoolUrl); + await editPoolPage.clickAssignToXMiners(amountOfMiners); + await editPoolPage.validateTextInToastGroup("Assigned pools"); + }); + + await test.step("Validate the pool has been assigned", async () => { + await minersPage.validateNoMinerWithIssue("Pool required"); + }); + }); + + test("Add pool created from settings and reorder", async ({ + settingsPage, + settingsPoolsPage, + newPoolModal, + minersPage, + editPoolPage, + commonSteps, + loginModal, + }) => { + const newPoolName1 = generateRandomText("PoolName1"); + const newPoolName2 = generateRandomText("PoolName2"); + const newPoolUsername1 = generatePoolUsername(); + const newPoolUsername2 = generatePoolUsername(); + + await test.step("Navigate to mining pools settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + await settingsPoolsPage.validateMiningPoolsPageOpened(); + }); + + await test.step("Add a pool", async () => { + await settingsPoolsPage.clickAddPool(); + await newPoolModal.inputPoolName(newPoolName1); + await newPoolModal.inputPoolUrl(validPoolUrl); + await newPoolModal.inputPoolUsername(newPoolUsername1); + await newPoolModal.clickSaveNewPool(); + await settingsPoolsPage.validatePoolEntryByUniqueName(newPoolName1, validPoolUrl, newPoolUsername1); + await settingsPoolsPage.validateTextInToast("Pool added"); + }); + + await commonSteps.goToMinersPage(); + + let minerIp: string; + let minerStatus: string; + + await test.step("Open pool editor for first miner", async () => { + minerIp = await minersPage.getMinerIpAddressByIndex(0); + minerStatus = await minersPage.getMinerStatus(minerIp); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + }); + + await test.step("Remove all existing pools from miner", async () => { + await editPoolPage.removeAllPools(); + }); + + await test.step("Add first pool to the miner", async () => { + await editPoolPage.clickAddPoolButton(); + await editPoolPage.validateModalIsOpen(); + await editPoolPage.clickPoolRowByName(newPoolName1); + await editPoolPage.clickSavePoolChoice(); + await editPoolPage.validateModalIsClosed(); + await editPoolPage.validatePoolCount(1); + }); + + await test.step("Add another pool to the miner", async () => { + await editPoolPage.clickAddAnotherPoolButton(); + await editPoolPage.clickAddNewPool(); + await editPoolPage.validateModalIsOpen(); + await newPoolModal.inputPoolName(newPoolName2); + await newPoolModal.inputPoolUrl(validPoolUrl); + await newPoolModal.inputPoolUsername(newPoolUsername2); + await newPoolModal.clickTestConnection(); + await newPoolModal.validateConnectionSuccessful(); + await newPoolModal.clickSaveNewPool(); + await editPoolPage.validateModalIsClosed(); + }); + + await test.step("Validate pool order", async () => { + await editPoolPage.validatePoolCount(2); + await editPoolPage.validatePoolByIndex(0, newPoolName1, validPoolUrl); + await editPoolPage.validatePoolByIndex(1, newPoolName2, validPoolUrl); + }); + + await test.step("Reorder mining pools", async () => { + await editPoolPage.reorderPoolByDragging(1, 0); + }); + + await test.step("Validate pool order after reorder", async () => { + await editPoolPage.validatePoolCount(2); + await editPoolPage.validatePoolByIndex(0, newPoolName2, validPoolUrl); + await editPoolPage.validatePoolByIndex(1, newPoolName1, validPoolUrl); + }); + + await test.step("Save pool changes", async () => { + await new Promise((resolve) => setTimeout(resolve, DEFAULT_INTERVAL)); + await editPoolPage.clickAssignToXMiners(1); + await editPoolPage.validateTextInToastGroup("Assigned pools"); + }); + + await test.step("Validate miner's status did not change", async () => { + await minersPage.validateMinerStatus(minerIp, minerStatus); + }); + + await test.step("Reopen miner and validate the pools have been saved successfully", async () => { + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickEditMiningPoolButton(); + await loginModal.loginAsAdmin(); + await editPoolPage.validatePoolCount(2); + expect(await editPoolPage.getPoolUrlByIndex(0)).toBe(validPoolUrl); + expect(await editPoolPage.getPoolUrlByIndex(1)).toBe(validPoolUrl); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts b/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts new file mode 100644 index 000000000..b88ec984b --- /dev/null +++ b/client/e2eTests/protoFleet/spec/addMinersValidation.spec.ts @@ -0,0 +1,181 @@ +/* eslint-disable playwright/expect-expect */ +import { expect, test } from "../fixtures/pageFixtures"; + +test.describe("Proto Fleet - Add Miners Validation", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.loginAsAdmin(); + }); + + test("Shows validation error dialog for invalid IP addresses", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter invalid IP addresses", async () => { + await addMinersPage.inputMinerIp("999.999.999.999, 256.1.1.1"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown with invalid entries", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidIpAddressesInDialog(["999.999.999.999", "256.1.1.1"]); + }); + }); + + test("Shows validation error dialog for invalid IP ranges", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter invalid IP range (end before start)", async () => { + await addMinersPage.inputMinerIp("192.168.1.100-50"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown with invalid range", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidIpRangesInDialog(["192.168.1.100-50"]); + }); + }); + + test("Shows validation error dialog for invalid subnets", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter invalid subnet (mask > 32)", async () => { + await addMinersPage.inputMinerIp("192.168.1.0/33"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown with invalid subnet", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidSubnetsInDialog(["192.168.1.0/33"]); + }); + }); + + test("Back to editing button closes dialog and returns to form", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + }); + + await test.step("Click back to editing", async () => { + await addMinersPage.clickBackToEditing(); + }); + + await test.step("Validate dialog is closed and form is still visible", async () => { + await addMinersPage.validateValidationErrorDialogIsClosed(); + // Verify the textarea is still accessible with the original value + const textarea = addMinersPage["page"].locator("#ipAddresses"); + await expect(textarea).toBeVisible(); + }); + + await test.step("Validate error message appears on textarea", async () => { + await addMinersPage.validateTextareaErrorContains("Check the format of the following and retry"); + await addMinersPage.validateTextareaErrorContains("999.999.999.999"); + }); + }); + + test("Continue anyway button proceeds with valid entries only", async ({ minersPage, addMinersPage, page }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate error dialog is shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + }); + + await test.step("Click continue anyway", async () => { + await addMinersPage.clickContinueAnyway(); + }); + + await test.step("Validate dialog is closed and discovery proceeds", async () => { + await addMinersPage.validateValidationErrorDialogIsClosed(); + // The pairing step should now be active (either loading or showing results) + const findingMinersTitle = page.getByText("Finding miners on your network"); + const foundMinersSection = page.getByText(/\d+ miners found/); + const noMinersFound = page.getByText(/No miners found/); + + // Wait for either the loading state, results, or no miners found + await expect(findingMinersTitle.or(foundMinersSection).or(noMinersFound)).toBeVisible({ timeout: 10000 }); + }); + }); + + test("Shows multiple error categories in dialog", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter multiple types of invalid entries", async () => { + await addMinersPage.inputMinerIp("999.999.999.999, 192.168.1.100-50, 192.168.1.0/33"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate all error categories are shown", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateInvalidIpAddressesInDialog(["999.999.999.999"]); + await addMinersPage.validateInvalidIpRangesInDialog(["192.168.1.100-50"]); + await addMinersPage.validateInvalidSubnetsInDialog(["192.168.1.0/33"]); + }); + }); + + test("Hides Continue anyway when all entries are invalid", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter only invalid entries", async () => { + await addMinersPage.inputMinerIp("999.999.999.999, 256.1.1.1"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate dialog shows only Back to editing button", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateContinueAnywayButtonNotVisible(); + }); + + await test.step("Back to editing works correctly", async () => { + await addMinersPage.clickBackToEditing(); + await addMinersPage.validateValidationErrorDialogIsClosed(); + }); + }); + + test("Shows Continue anyway when mix of valid and invalid entries", async ({ minersPage, addMinersPage }) => { + await test.step("Navigate to add miners flow", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.clickAddMinersButton(); + }); + + await test.step("Enter mix of valid and invalid entries", async () => { + await addMinersPage.inputMinerIp("192.168.1.1, 999.999.999.999"); + await addMinersPage.clickFindMinersByIp(); + }); + + await test.step("Validate dialog shows both buttons", async () => { + await addMinersPage.validateValidationErrorDialogIsVisible(); + await addMinersPage.validateContinueAnywayButtonVisible(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts b/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts new file mode 100644 index 000000000..2b2e22f00 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/apiKeysSettings.spec.ts @@ -0,0 +1,110 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsApiKeysPage } from "../pages/settingsApiKeys"; + +const API_KEY_PREFIX = "e2e_api_key"; + +test.describe("Proto Fleet - API Keys", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Revoke any API keys created during tests", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const viewport = testInfo.project.use?.viewport; + const context = await browser.newContext({ baseURL: testConfig.baseUrl, viewport }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsApiKeysPage = new SettingsApiKeysPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await settingsApiKeysPage.navigateToApiKeysSettings(); + await settingsApiKeysPage.deleteApiKeysByPrefix(API_KEY_PREFIX); + } finally { + await context.close(); + } + }); + + test("Create and revoke API key", async ({ commonSteps, settingsApiKeysPage }) => { + const apiKeyName = generateRandomText(API_KEY_PREFIX); + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to API Keys settings", async () => { + await settingsApiKeysPage.navigateToApiKeysSettings(); + await settingsApiKeysPage.validateApiKeysPageOpened(); + }); + + await test.step("Create a new API key without expiration", async () => { + await settingsApiKeysPage.clickCreateApiKey(); + await settingsApiKeysPage.inputApiKeyName(apiKeyName); + await settingsApiKeysPage.clickCreateInModal(); + await settingsApiKeysPage.validateApiKeyCreated(); + await settingsApiKeysPage.clickDone(); + }); + + await test.step("Validate the API key appears in the list", async () => { + await settingsApiKeysPage.validateApiKeyVisible(apiKeyName); + await settingsApiKeysPage.validateApiKeyHasNoExpiration(apiKeyName); + }); + + await test.step("Revoke the API key", async () => { + await settingsApiKeysPage.clickRevokeApiKey(apiKeyName); + await settingsApiKeysPage.confirmRevokeApiKey(); + await settingsApiKeysPage.validateApiKeyNotVisible(apiKeyName); + }); + }); + + test("Expiration validation", async ({ commonSteps, settingsApiKeysPage }) => { + const apiKeyName = generateRandomText(API_KEY_PREFIX); + const today = new Date(); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 2); + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to API Keys settings", async () => { + await settingsApiKeysPage.navigateToApiKeysSettings(); + await settingsApiKeysPage.validateApiKeysPageOpened(); + }); + + await test.step("Validate key name is required", async () => { + await settingsApiKeysPage.clickCreateApiKey(); + await settingsApiKeysPage.clickCreateInModal(); + await settingsApiKeysPage.validateApiKeyNameRequired(); + }); + + await test.step("Validate today cannot be selected as an expiration date", async () => { + await settingsApiKeysPage.openExpirationDatePicker(); + await settingsApiKeysPage.validateExpirationDayDisabled(today.getDate()); + }); + + await test.step("Create a new API key with a future expiration date", async () => { + await settingsApiKeysPage.selectExpirationDate(futureDate); + await settingsApiKeysPage.inputApiKeyName(apiKeyName); + await settingsApiKeysPage.clickCreateInModal(); + await settingsApiKeysPage.validateApiKeyCreated(); + await settingsApiKeysPage.clickDone(); + }); + + await test.step("Validate the API key expiration is saved", async () => { + await settingsApiKeysPage.validateApiKeyVisible(apiKeyName); + await settingsApiKeysPage.validateApiKeyHasExpiration(apiKeyName); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/auth.spec.ts b/client/e2eTests/protoFleet/spec/auth.spec.ts new file mode 100644 index 000000000..178b66853 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/auth.spec.ts @@ -0,0 +1,24 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; + +test.describe("Proto Fleet - Authentication", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Sign in with admin", async ({ authPage, settingsPage, settingsTeamPage }) => { + await test.step("Log in as admin user", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate to Team Settings and validate admin access", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + await settingsTeamPage.validateIsAdmin(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/firmware.spec.ts b/client/e2eTests/protoFleet/spec/firmware.spec.ts new file mode 100644 index 000000000..359599b83 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/firmware.spec.ts @@ -0,0 +1,128 @@ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsFirmwarePage } from "../pages/settingsFirmware"; + +async function cleanupUpdatedRigMiner(minersPage: MinersPage, rigMinerIp: string) { + const currentStatus = (await minersPage.getMinerStatus(rigMinerIp)).trim(); + + if (currentStatus === "Updating firmware") { + await minersPage.validateMinerStatusSettled(rigMinerIp, "Reboot required", testConfig.testTimeout); + } + + const rebootRequiredStatus = (await minersPage.getMinerStatus(rigMinerIp)).trim(); + if (rebootRequiredStatus === "Reboot required") { + await minersPage.clickMinerThreeDotsButton(rigMinerIp); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + await minersPage.validateMinerStatusSettled(rigMinerIp, "Hashing"); + } +} + +test.describe("Firmware", () => { + let updatedRigMinerIp = ""; + + // eslint-disable-next-line playwright/no-skipped-test + test.skip(testConfig.target === "real", "Firmware update E2E is only supported against the fake proto rig setup."); + + test.beforeEach(async ({ page, commonSteps }) => { + updatedRigMinerIp = ""; + await page.goto("/"); + await commonSteps.loginAsAdmin(); + }); + + test.afterEach( + "CLEANUP: Reboot updated rig miners and delete uploaded firmware files", + async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ + baseURL: testConfig.baseUrl, + viewport: testInfo.project.use?.viewport, + }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsFirmwarePage = new SettingsFirmwarePage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + await minersPage.navigateToMinersPage(); + await minersPage.waitForMinersListToLoad(); + await minersPage.filterRigMiners(); + if (updatedRigMinerIp) { + await cleanupUpdatedRigMiner(minersPage, updatedRigMinerIp); + } + + await settingsFirmwarePage.navigateToFirmwareSettings(); + await settingsFirmwarePage.validateFirmwarePageOpened(); + await settingsFirmwarePage.deleteAllFirmwareFilesIfAny(); + } finally { + updatedRigMinerIp = ""; + await context.close(); + } + }, + ); + + test("Upload firmware and update a rig miner", async ({ minersPage, settingsFirmwarePage }) => { + test.setTimeout(testConfig.testTimeout * 4); + + const firmwareFileName = `firmware-${Date.now()}.swu`; + const firmwareFileContents = `fake firmware payload ${Date.now()}`; + const firmwareStatusTimeout = testConfig.testTimeout; + + await test.step("Upload a firmware payload in Settings", async () => { + await settingsFirmwarePage.navigateToFirmwareSettings(); + await settingsFirmwarePage.validateFirmwarePageOpened(); + await settingsFirmwarePage.deleteAllFirmwareFilesIfAny(); + + await settingsFirmwarePage.clickUploadFirmware(); + await settingsFirmwarePage.uploadFirmwareFile(firmwareFileName, firmwareFileContents); + await settingsFirmwarePage.clickDoneInUploadDialog(); + await settingsFirmwarePage.validateTextInToast("Firmware file uploaded successfully"); + await settingsFirmwarePage.validateFirmwareFileVisible(firmwareFileName); + }); + + let rigMinerIp = ""; + + await test.step("Pick a hashing rig miner", async () => { + await minersPage.navigateToMinersPage(); + await minersPage.waitForMinersListToLoad(); + await minersPage.filterRigMiners(); + await test.expect + .poll(async () => await minersPage.hasAnyMinerWithStatus("Hashing"), { + timeout: testConfig.testTimeout, + }) + .toBe(true); + + rigMinerIp = await minersPage.getMinerIpAddressByStatus("Hashing"); + updatedRigMinerIp = rigMinerIp; + }); + + await test.step("Start the firmware update from the miner actions menu", async () => { + await minersPage.clickMinerThreeDotsButton(rigMinerIp); + await minersPage.clickUpdateFirmwareButton(); + await minersPage.validateFirmwareUpdateModalOpened(); + await minersPage.selectExistingFirmwareFile(firmwareFileName); + await minersPage.clickContinueInFirmwareUpdateModal(); + }); + + await test.step("Validate the miner transitions through firmware update states", async () => { + await minersPage.validateMinerStatusSettled(rigMinerIp, "Updating firmware", firmwareStatusTimeout); + await minersPage.validateMinerStatusSettled(rigMinerIp, "Reboot required", firmwareStatusTimeout); + }); + + await test.step("Reboot the miner and validate it returns to hashing", async () => { + await minersPage.clickMinerThreeDotsButton(rigMinerIp); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + await minersPage.validateMinerStatusSettled(rigMinerIp, "Hashing"); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/generalSettings.spec.ts b/client/e2eTests/protoFleet/spec/generalSettings.spec.ts new file mode 100644 index 000000000..bb3607589 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/generalSettings.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsPage } from "../pages/settings"; + +test.describe("General Settings", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Ensure temperature is Celsius", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + const page = await context.newPage(); + await page.goto("/"); + + try { + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await authPage.navigateToSettingsPage(); + + const currentTemperature = await settingsPage.getCurrentTemperatureFormat(); + + if (currentTemperature !== "Celsius") { + await settingsPage.clickTemperatureButton(); + await settingsPage.selectCelsius(); + await settingsPage.clickDoneButton(); + await settingsPage.validateTemperatureFormatCelsius(); + } + } finally { + await context.close(); + } + }); + + test("Set temperature format", async ({ authPage, settingsPage, minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to general settings", async () => { + await authPage.navigateToSettingsPage(); + }); + + await test.step("Set temperature to Fahrenheit", async () => { + await settingsPage.clickTemperatureButton(); + await settingsPage.selectFahrenheit(); + await settingsPage.clickDoneButton(); + await settingsPage.validateTemperatureFormatFahrenheit(); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Verify miner temperature is displayed in Fahrenheit", async () => { + await minersPage.validateTemperatureUnitFahrenheit(); + }); + + await test.step("Navigate back to settings", async () => { + await authPage.navigateToSettingsPage(); + }); + + await test.step("Change temperature format to Celsius", async () => { + await settingsPage.clickTemperatureButton(); + await settingsPage.selectCelsius(); + await settingsPage.clickDoneButton(); + await settingsPage.validateTemperatureFormatCelsius(); + }); + + await commonSteps.goToMinersPage(); + + await test.step("Verify miner temperature is displayed in Celsius", async () => { + await minersPage.validateTemperatureUnitCelsius(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/groups.spec.ts b/client/e2eTests/protoFleet/spec/groups.spec.ts new file mode 100644 index 000000000..525f794e4 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/groups.spec.ts @@ -0,0 +1,345 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { PROTO_RIG_MODEL } from "../helpers/minerModels"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { GroupsPage } from "../pages/groups"; +import { MinersPage } from "../pages/miners"; + +test.describe("Groups", () => { + test.beforeEach(async ({ page, groupsPage, commonSteps }) => { + await page.goto("/"); + await commonSteps.loginAsAdmin(); + await groupsPage.navigateToGroupsPage(); + await cleanupAllGroups(groupsPage); + }); + + test.afterAll("CLEANUP: Delete all groups", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const groupsPage = new GroupsPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await groupsPage.navigateToGroupsPage(); + await cleanupAllGroups(groupsPage); + } finally { + await context.close(); + } + }); + + async function cleanupAllGroups(groupsPage: GroupsPage) { + const existingGroupNames = await groupsPage.listSavedGroupNames(); + + if (existingGroupNames.length === 0) { + return; + } + + const automationGroups = existingGroupNames.filter((groupName) => groupName.startsWith("automation")); + + for (const groupName of automationGroups) { + await groupsPage.openSavedGroup(groupName); + await groupsPage.clickDeleteGroupInModal(); + await groupsPage.clickDeleteConfirm(); + await groupsPage.validateSavedGroupNotVisible(groupName); + } + } + + test("Create, edit, and delete groups", async ({ groupsPage }) => { + const groupName = generateRandomText("automation"); + const editedGroupName = generateRandomText("automation-edited"); + + await test.step("Create new group with all miners", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(groupName); + + await groupsPage.waitForModalListToLoad(); + const allMinersCount = await groupsPage.getModalListRowCount(); + + await groupsPage.clickSelectAllCheckboxInModal(); + await groupsPage.clickSaveInModal(); + + await groupsPage.validateTextInToast(`Group "${groupName}" created`); + await groupsPage.validateSavedGroupVisible(groupName); + await groupsPage.validateSavedGroupMinerCount(groupName, allMinersCount); + }); + + await test.step("Edit group to only rig miners", async () => { + await groupsPage.openSavedGroup(groupName); + await groupsPage.waitForModalListToLoad(); + + await groupsPage.inputGroupName(editedGroupName); + + // clear previous selection + await groupsPage.clickSelectAllCheckboxInModal(); + + await groupsPage.filterModalType(PROTO_RIG_MODEL); + await groupsPage.waitForModalListToLoad(); + + await groupsPage.clickSelectAllCheckboxInModal(); + const rigMinersCount = await groupsPage.getModalListRowCount(); + + await groupsPage.clickSaveInModal(); + + await groupsPage.validateTextInToast(`Group "${editedGroupName}" updated`); + await groupsPage.validateSavedGroupVisible(editedGroupName); + await groupsPage.validateSavedGroupMinerCount(editedGroupName, rigMinersCount); + }); + + await test.step("Delete group", async () => { + await groupsPage.openSavedGroup(editedGroupName); + await groupsPage.clickDeleteGroupInModal(); + await groupsPage.validateTitle(`Delete "${editedGroupName}"?`); + await groupsPage.clickDeleteConfirm(); + + await groupsPage.validateTextInToast(`Group "${editedGroupName}" deleted`); + await groupsPage.validateSavedGroupNotVisible(editedGroupName); + }); + }); + + test("Validate groups association to miners", async ({ groupsPage }) => { + const group1Name = generateRandomText("automation1"); + const group2Name = generateRandomText("automation2"); + const group3Name = generateRandomText("automation3"); + const minerIps: string[] = []; + + await test.step("Capture 5 clean miners with no existing groups", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + minerIps.push(...(await groupsPage.getUngroupedMinerIps(5))); + test.expect(minerIps).toHaveLength(5); + await groupsPage.closeModal(); + }); + + await test.step("Create group1 with miners 0-2", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(group1Name); + await groupsPage.waitForModalListToLoad(); + for (const ip of minerIps.slice(0, 3)) { + await groupsPage.selectMinerByIp(ip); + } + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${group1Name}" created`); + await groupsPage.validateSavedGroupVisible(group1Name); + await groupsPage.validateSavedGroupMinerCount(group1Name, 3); + }); + + await test.step("Validate specific miners have group1 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[2], group1Name); + await groupsPage.closeModal(); + }); + + await test.step("Create group2 with miners 1-3", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(group2Name); + await groupsPage.waitForModalListToLoad(); + for (const ip of minerIps.slice(1, 4)) { + await groupsPage.selectMinerByIp(ip); + } + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${group2Name}" created`); + await groupsPage.validateSavedGroupVisible(group2Name); + await groupsPage.validateSavedGroupMinerCount(group2Name, 3); + }); + + await test.step("Validate specific miners have group1 & group2 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], `${group1Name}, ${group2Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[2], `${group1Name}, ${group2Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[3], group2Name); + await groupsPage.closeModal(); + }); + + await test.step("Create group3 with miners 2-4", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(group3Name); + await groupsPage.waitForModalListToLoad(); + for (const ip of minerIps.slice(2, 5)) { + await groupsPage.selectMinerByIp(ip); + } + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${group3Name}" created`); + await groupsPage.validateSavedGroupVisible(group3Name); + await groupsPage.validateSavedGroupMinerCount(group3Name, 3); + }); + + await test.step("Validate specific miners have group1, group2 & group3 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], `${group1Name}, ${group2Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[2], `${group1Name}, ${group2Name}, ${group3Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[3], `${group2Name}, ${group3Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[4], group3Name); + await groupsPage.closeModal(); + }); + + await test.step("Validate each group filter shows correct miners", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + + await groupsPage.filterModalGroup(group1Name); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateOnlyTheseIpsVisibleInModal([minerIps[0], minerIps[1], minerIps[2]]); + + await groupsPage.filterModalGroup(group2Name); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateOnlyTheseIpsVisibleInModal([minerIps[1], minerIps[2], minerIps[3]]); + + await groupsPage.filterModalGroup(group3Name); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateOnlyTheseIpsVisibleInModal([minerIps[2], minerIps[3], minerIps[4]]); + + await groupsPage.closeModal(); + }); + + await test.step("Delete group2", async () => { + await groupsPage.openSavedGroup(group2Name); + await groupsPage.clickDeleteGroupInModal(); + await groupsPage.validateTitle(`Delete "${group2Name}"?`); + await groupsPage.clickDeleteConfirm(); + await groupsPage.validateTextInToast(`Group "${group2Name}" deleted`); + await groupsPage.validateSavedGroupNotVisible(group2Name); + }); + + await test.step("Validate specific miners have group1, group3 in group column", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.waitForModalListToLoad(); + await groupsPage.validateMinerGroupsByIp(minerIps[0], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[1], group1Name); + await groupsPage.validateMinerGroupsByIp(minerIps[2], `${group1Name}, ${group3Name}`); + await groupsPage.validateMinerGroupsByIp(minerIps[3], group3Name); + await groupsPage.validateMinerGroupsByIp(minerIps[4], group3Name); + await groupsPage.closeModal(); + }); + }); + + test("Cannot create group with no title or miners or with duplicate name", async ({ groupsPage }) => { + const groupName = generateRandomText("automation1"); + const secondGroupName = generateRandomText("automation2"); + + await test.step("Try to create a group without a title", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.clickSaveInModal(); + }); + + await test.step("Validate missing name error", async () => { + await groupsPage.validateErrorMessage("Group name is required"); + }); + + await test.step("Try to create a group without any miner", async () => { + await groupsPage.inputGroupName(groupName); + await groupsPage.clickSaveInModal(); + }); + + await test.step("Validate no miners selected error", async () => { + await groupsPage.validateErrorMessage("Select at least one miner"); + }); + + await test.step("Finish creating a valid group", async () => { + await groupsPage.clickSelectAllCheckboxInModal(); + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${groupName}" created`); + await groupsPage.validateSavedGroupVisible(groupName); + }); + + await test.step("Try to create a group with an existing group name", async () => { + await groupsPage.clickAddGroupButton(); + await groupsPage.inputGroupName(groupName); + await groupsPage.clickSelectAllCheckboxInModal(); + await groupsPage.clickSaveInModal(); + }); + + await test.step("Validate duplicate group name error", async () => { + await groupsPage.validateErrorMessage("A group with this name already exists"); + }); + + await test.step("Finish creating a second valid group", async () => { + await groupsPage.inputGroupName(secondGroupName); + await groupsPage.clickSaveInModal(); + await groupsPage.validateTextInToast(`Group "${secondGroupName}" created`); + await groupsPage.validateSavedGroupVisible(groupName); + await groupsPage.validateSavedGroupVisible(secondGroupName); + }); + }); + + test("Create a group with all miners from Miners page and reboot group from Groups page", async ({ + minersPage, + groupsPage, + commonSteps, + }) => { + const groupName = generateRandomText("automation"); + let minerCount: number; + + await test.step("Go to miners page", async () => { + await commonSteps.goToMinersPage(); + }); + + await test.step("Select all miners and create group", async () => { + minerCount = await minersPage.getMinersCount(); + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickAddToGroupButton(); + await minersPage.inputNewGroupName(groupName); + await minersPage.clickSaveInModal(); + }); + + await test.step("Validate group creation success", async () => { + await minersPage.validateTextInToast(`Added ${minerCount} miners to group`); + }); + + await test.step("Reload page (workaround for DASH-1435)", async () => { + await minersPage.reloadPage(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + }); + + await test.step("Validate group name in group column for all miners", async () => { + const currentMinerCount = await minersPage.getMinersCount(); + for (let i = 0; i < currentMinerCount; i++) { + const minerIp = await minersPage.getMinerIpAddressByIndex(i); + await minersPage.validateMinerGroupName(minerIp, groupName); + } + }); + + await test.step("Navigate to groups page and validate group", async () => { + await groupsPage.navigateToGroupsPage(); + await groupsPage.validateSavedGroupVisible(groupName); + await groupsPage.validateSavedGroupMinerCount(groupName, minerCount); + }); + + await test.step("Reboot group from Groups page", async () => { + await groupsPage.clickGroupActionsButton(groupName); + await groupsPage.clickRebootGroupButton(); + await groupsPage.validateRebootConfirmationModal(minerCount); + await groupsPage.clickRebootConfirm(); + }); + + await test.step("Validate reboot success", async () => { + await groupsPage.validateTextInToastGroup(`Rebooted ${minerCount} out of ${minerCount} miners`); + }); + + await test.step("Navigate to miners page and validate rebooting status", async () => { + await commonSteps.goToMinersPage(); + await minersPage.validateAllMinersStatus("Rebooting"); + }); + + await test.step("Wait for Hashing status (reduce risk of causing issues to the next test)", async () => { + await minersPage.validateNoMinerWithStatus("Rebooting"); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minerIssues.spec.ts b/client/e2eTests/protoFleet/spec/minerIssues.spec.ts new file mode 100644 index 000000000..d6a9580bf --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minerIssues.spec.ts @@ -0,0 +1,219 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; +import { IssueIcon } from "../helpers/testDataHelper"; + +test.describe("Miner Issues Tests", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("mock ErrorQueryService with custom errors", async ({ page, minersPage, commonSteps }) => { + const errorControlBoard = "COMPONENT_TYPE_CONTROL_BOARD"; + const errorHashBoard = "COMPONENT_TYPE_HASH_BOARD"; + const errorPsu = "COMPONENT_TYPE_PSU"; + const errorFan = "COMPONENT_TYPE_FAN"; + const date = "2026-01-01T12:00:00.203124Z"; + let testMiners: Array<{ deviceIdentifier: string; ipAddress: string; name: string }> = []; + + await commonSteps.loginAsAdmin(); + + await test.step("Capture miner data from ListMinerStateSnapshots", async () => { + const expectedMinerCount = 5; + const responsePromise = page.waitForResponse(async (response) => { + if (!response.url().includes("ListMinerStateSnapshots")) return false; + const data = await response.json(); + return Array.isArray(data.miners) && data.miners.length >= expectedMinerCount; + }); + await commonSteps.goToMinersPage(); + const response = await responsePromise; + const responseData = await response.json(); + + testMiners = responseData.miners.map((miner: { deviceIdentifier: string; ipAddress: string; name: string }) => ({ + deviceIdentifier: miner.deviceIdentifier, + ipAddress: miner.ipAddress, + name: miner.name, + })); + }); + + await test.step("Setup error mock for ErrorQueryService", async () => { + const mockErrorData = { + devices: { + items: [ + { + deviceIdentifier: testMiners[0].deviceIdentifier, + errors: [ + { + errorId: "test-error-1", + summary: errorControlBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[0].deviceIdentifier, + componentType: errorControlBoard, + }, + ], + }, + { + deviceIdentifier: testMiners[1].deviceIdentifier, + errors: [ + { + errorId: "test-error-2", + summary: errorHashBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[1].deviceIdentifier, + componentType: errorHashBoard, + }, + ], + }, + { + deviceIdentifier: testMiners[2].deviceIdentifier, + errors: [ + { + errorId: "test-error-3", + summary: errorPsu, + lastSeenAt: date, + deviceIdentifier: testMiners[2].deviceIdentifier, + componentType: errorPsu, + }, + ], + }, + { + deviceIdentifier: testMiners[3].deviceIdentifier, + errors: [ + { + errorId: "test-error-4", + summary: errorFan, + lastSeenAt: date, + deviceIdentifier: testMiners[3].deviceIdentifier, + componentType: errorFan, + }, + ], + }, + { + deviceIdentifier: testMiners[4].deviceIdentifier, + errors: [ + { + errorId: "test-error-5a", + summary: errorControlBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorControlBoard, + }, + { + errorId: "test-error-5b", + summary: errorHashBoard, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorHashBoard, + }, + { + errorId: "test-error-5c", + summary: errorPsu, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorPsu, + }, + { + errorId: "test-error-5d", + summary: errorFan, + lastSeenAt: date, + deviceIdentifier: testMiners[4].deviceIdentifier, + componentType: errorFan, + }, + ], + }, + ], + }, + }; + + await page.route(/ErrorQueryService\/Query/, async (route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mockErrorData), + }); + }); + + await minersPage.reloadPage(); + await minersPage.validateMinersPageOpened(); + }); + + await test.step("Validate first miner icons and status modal - CONTROL BOARD failure", async () => { + const ip = testMiners[0].ipAddress; + const name = testMiners[0].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.CONTROL_BOARD); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Control board failure"); + await minersPage.validateErrorInModal(errorControlBoard, IssueIcon.CONTROL_BOARD); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate second miner icons and status modal - HASHBOARD failure", async () => { + const ip = testMiners[1].ipAddress; + const name = testMiners[1].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.HASH_BOARD); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Hashboard failure"); + await minersPage.validateErrorInModal(errorHashBoard, IssueIcon.HASH_BOARD); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate third miner icons and status modal - PSU failure", async () => { + const ip = testMiners[2].ipAddress; + const name = testMiners[2].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.PSU); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("PSU failure"); + await minersPage.validateErrorInModal(errorPsu, IssueIcon.PSU); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate fourth miner icons and status modal - FAN failure", async () => { + const ip = testMiners[3].ipAddress; + const name = testMiners[3].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.FAN); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Fan failure"); + await minersPage.validateErrorInModal(errorFan, IssueIcon.FAN); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate fifth miner icons and status modal - Multiple failures", async () => { + const ip = testMiners[4].ipAddress; + const name = testMiners[4].name; + await minersPage.validateMinerIcon(ip, "issues", IssueIcon.GENERAL_ALERT); + await minersPage.validateMinerIcon(ip, "name", IssueIcon.GENERAL_ALERT); + await minersPage.clickMinerElementAndExpectModal(ip, "issues", name); + await minersPage.validateTitleInModal("Multiple failures"); + await minersPage.validateErrorInModal(errorControlBoard, IssueIcon.CONTROL_BOARD); + await minersPage.validateErrorInModal(errorHashBoard, IssueIcon.HASH_BOARD); + await minersPage.validateErrorInModal(errorPsu, IssueIcon.PSU); + await minersPage.validateErrorInModal(errorFan, IssueIcon.FAN); + await minersPage.clickCloseStatusModal(); + }); + + const firstMinerIp = testMiners[0].ipAddress; + const firstMinerName = testMiners[0].name; + + await test.step("Validate modal can be opened from alert icon", async () => { + // From general alert icon + await minersPage.clickMinerElementAndExpectModal(firstMinerIp, "alert-icon", firstMinerName); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate modal can be opened from status column", async () => { + // From status column + await minersPage.clickMinerElementAndExpectModal(firstMinerIp, "status", firstMinerName); + await minersPage.clickCloseStatusModal(); + }); + + await test.step("Validate modal can be opened from issues column", async () => { + // From issues column + await minersPage.clickMinerElementAndExpectModal(firstMinerIp, "issues", firstMinerName); + await minersPage.clickCloseStatusModal(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersActions.spec.ts b/client/e2eTests/protoFleet/spec/minersActions.spec.ts new file mode 100644 index 000000000..f1b38f131 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersActions.spec.ts @@ -0,0 +1,362 @@ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Miners", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("REBOOT a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/Reboot/); + const responsePromise = page.waitForResponse(/Reboot/); + + await test.step("Select first miner and reboot it", async () => { + let minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Rebooting"); + await minersPage.validateTextInToastGroup("Rebooted"); + }); + + await test.step("Validate reboot API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("REBOOT multiple miners", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/Reboot/); + const responsePromise = page.waitForResponse(/Reboot/); + + await test.step("Select multiple miners and reboot them", async () => { + let minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + let minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + let minerIp3 = await minersPage.getMinerIpAddressByIndex(2); + + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + + await minersPage.clickActionsMenuButton(); + await minersPage.clickRebootButton(); + await minersPage.clickRebootConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Rebooting"); + await minersPage.validateTextInToastGroup("Rebooted"); + }); + + await test.step("Validate reboot API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + test.expect(request.method()).toBe("POST"); + const requestBody = request.postDataJSON(); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + }); + + test("MANAGE POWER for a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support MANAGE POWER action + await minersPage.filterRigMiners(); + }); + + const requestPromise1 = page.waitForRequest(/SetPowerTarget/); + const responsePromise1 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select first miner and set MAX power", async () => { + let minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickManagePowerButton(); + await minersPage.clickMaxPowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + await minersPage.dismissToast(); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise1; + const response = await responsePromise1; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_MAXIMUM_HASHRATE"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + + const requestPromise2 = page.waitForRequest(/SetPowerTarget/); + const responsePromise2 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select first miner and set REDUCE power", async () => { + let minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickManagePowerButton(); + await minersPage.clickReducePowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise2; + const response = await responsePromise2; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("MANAGE POWER for multiple miners", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support MANAGE POWER action + await minersPage.filterRigMiners(); + }); + + const requestPromise1 = page.waitForRequest(/SetPowerTarget/); + const responsePromise1 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select multiple miners and set MAX power", async () => { + let minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + let minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + let minerIp3 = await minersPage.getMinerIpAddressByIndex(2); + + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + + await minersPage.clickActionsMenuButton(); + await minersPage.clickManagePowerButton(); + await minersPage.clickMaxPowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + await minersPage.dismissToast(); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise1; + const response = await responsePromise1; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_MAXIMUM_HASHRATE"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + + const requestPromise2 = page.waitForRequest(/SetPowerTarget/); + const responsePromise2 = page.waitForResponse(/SetPowerTarget/); + + await test.step("Select multiple miners and set REDUCE power", async () => { + await minersPage.clickActionsMenuButton(); + await minersPage.clickManagePowerButton(); + await minersPage.clickReducePowerOption(); + await minersPage.clickManagePowerConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Updating power settings"); + await minersPage.validateTextInToastGroup("Updated power settings"); + }); + + await test.step("Validate 'SetPowerTarget' API request", async () => { + const request = await requestPromise2; + const response = await responsePromise2; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("performanceMode"); + test.expect(requestBody.performanceMode).toBe("PERFORMANCE_MODE_EFFICIENCY"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + }); + + test("Set COOLING MODE to Air Cooled for a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support COOLING_MODE action + await minersPage.filterRigMiners(); + }); + + const requestPromise = page.waitForRequest(/SetCoolingMode/); + const responsePromise = page.waitForResponse(/SetCoolingMode/); + + await test.step("Select first miner and set Air Cooled mode", async () => { + const minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickCoolingModeButton(); + await minersPage.validateAirCooledOptionSelected(); + await minersPage.clickUpdateCoolingModeConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Setting cooling mode"); + await minersPage.validateTextInToastGroup("Updated cooling mode"); + }); + + await test.step("Validate 'SetCoolingMode' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("mode"); + test.expect(requestBody.mode).toBe("COOLING_MODE_AIR_COOLED"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("Set COOLING MODE to Immersion Cooled for a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support COOLING_MODE action + await minersPage.filterRigMiners(); + }); + + const requestPromise = page.waitForRequest(/SetCoolingMode/); + const responsePromise = page.waitForResponse(/SetCoolingMode/); + + await test.step("Select first miner and set Immersion Cooled mode", async () => { + const minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickCoolingModeButton(); + await minersPage.clickImmersionCooledOption(); + await minersPage.clickUpdateCoolingModeConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Setting cooling mode"); + await minersPage.validateTextInToastGroup("Updated cooling mode"); + }); + + await test.step("Validate 'SetCoolingMode' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("mode"); + test.expect(requestBody.mode).toBe("COOLING_MODE_IMMERSION_COOLED"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + }); + + test("Set COOLING MODE for multiple miners", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto miners as a workaround", async () => { + // Workaround: Antminer miners don't support COOLING_MODE action + await minersPage.filterRigMiners(); + }); + + const requestPromise = page.waitForRequest(/SetCoolingMode/); + const responsePromise = page.waitForResponse(/SetCoolingMode/); + + await test.step("Select multiple miners and set Air Cooled mode", async () => { + const minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + const minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + const minerIp3 = await minersPage.getMinerIpAddressByIndex(2); + + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + + await minersPage.clickActionsMenuButton(); + await minersPage.clickCoolingModeButton(); + await minersPage.clickAirCooledOption(); + await minersPage.clickUpdateCoolingModeConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Setting cooling mode"); + await minersPage.validateTextInToastGroup("Updated cooling mode"); + }); + + await test.step("Validate 'SetCoolingMode' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("mode"); + test.expect(requestBody.mode).toBe("COOLING_MODE_AIR_COOLED"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(3); + test.expect(response.status()).toBe(200); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts b/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts new file mode 100644 index 000000000..bdc1353a9 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersAddRemove.spec.ts @@ -0,0 +1,222 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AddMinersPage } from "../pages/addMiners"; +import { AuthPage } from "../pages/auth"; +import { HomePage } from "../pages/home"; +import { MinersPage } from "../pages/miners"; + +test.describe("Miners UNPAIR - ADD actions", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Add miners, re-authenticate", async ({ browser }, testInfo) => { + if (testConfig.target === "real") { + return; + } + + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const homePage = new HomePage(page, isMobile); + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const addMinersPage = new AddMinersPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + // Step 1: Add miners from network if any are available + await minersPage.navigateToMinersPage(); + + const addMinersButtonClicked = await minersPage.tryAction(() => minersPage.clickAddMinersButton()); + if (!addMinersButtonClicked) { + await authPage.clickGetStarted(); + } + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.waitForFoundMinersList(); + const foundMinerCount = await addMinersPage.getFoundMinersCount(); + + if (foundMinerCount === 0) { + await addMinersPage.clickHeaderIconButton(); + } else { + await addMinersPage.clickContinueWithSelectedMiners(); + } + await minersPage.waitForMinersListToLoad(); + + // Step 2: Re-authenticate miners if needed (existing logic) + const authenticateMinersButtonClicked = await homePage.tryAction(() => homePage.clickAuthenticateMinersButton()); + if (authenticateMinersButtonClicked) { + await homePage.validateAuthenticateMinersModalTitle(); + await homePage.clickShowMinersButton(); + const miners = await homePage.getListOfMinersToAuthenticate(); + + if (miners.some((miner) => miner.includes("S17 XP"))) { + await homePage.inputMinerAuthUsername("root17"); + await homePage.inputMinerAuthPassword("root17"); + await homePage.clickAuthenticateMinersConfirmButton(); + } + if (miners.some((miner) => miner.includes("S19 XP"))) { + await homePage.inputMinerAuthUsername("root19"); + await homePage.inputMinerAuthPassword("root19"); + await homePage.clickAuthenticateMinersConfirmButton(); + } + if (miners.some((miner) => miner.includes("S21 XP"))) { + await homePage.inputMinerAuthUsername("root21"); + await homePage.inputMinerAuthPassword("root21"); + await homePage.clickAuthenticateMinersConfirmButton(); + } + await homePage.validateModalClosed(); + } + } finally { + await context.close(); + } + }); + + test("UNPAIR - ADD a single miner", async ({ minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let minerCount: number; + let minerIp: string; + + await test.step("Select a miner and unpair it", async () => { + minerCount = await minersPage.getMinersCount(); + minerIp = await minersPage.getAuthenticatedMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickUnpairButton(); + await minersPage.clickUnpairConfirm(); + }); + + await test.step("Validate miner was unpaired", async () => { + await minersPage.validateMinerNotPresent(minerIp); + await minersPage.validateAmountOfMiners(minerCount - 1); + }); + + await test.step("Add a single miner", async () => { + await minersPage.clickAddMinersButton(); + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickContinueWithXMiners(1); + }); + + await test.step("Validate miner was added", async () => { + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinerInList(minerIp); + await minersPage.validateAmountOfMiners(minerCount); + }); + }); + + test("UNPAIR - ADD multiple miners", async ({ minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let minerCount: number; + let minerIp1: string; + let minerIp2: string; + let minerIp3: string; + + await test.step("Select multiple miners and unpair them", async () => { + minerCount = await minersPage.getMinersCount(); + minerIp1 = await minersPage.getAuthenticatedMinerIpAddressByIndex(0); + minerIp2 = await minersPage.getAuthenticatedMinerIpAddressByIndex(1); + minerIp3 = await minersPage.getAuthenticatedMinerIpAddressByIndex(2); + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickMinerCheckbox(minerIp3); + await minersPage.validateActionBarMinerCount(3); + await minersPage.clickActionsMenuButton(); + await minersPage.clickUnpairButton(); + await minersPage.clickUnpairConfirm(); + }); + + await test.step("Validate miners were unpaired", async () => { + await minersPage.validateMinerNotPresent(minerIp1); + await minersPage.validateMinerNotPresent(minerIp2); + await minersPage.validateMinerNotPresent(minerIp3); + await minersPage.validateAmountOfMiners(minerCount - 3); + }); + + await test.step("Add multiple miners", async () => { + await minersPage.clickAddMinersButton(); + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickChooseMiners(); + await addMinersPage.clickSelectNone(); + await addMinersPage.clickMinerCheckbox(minerIp1); + await addMinersPage.clickMinerCheckbox(minerIp2); + await addMinersPage.clickMinerCheckbox(minerIp3); + await addMinersPage.clickDone(); + await addMinersPage.clickContinueWithXMiners(3); + }); + + await test.step("Validate miners were added", async () => { + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinerInList(minerIp1); + await minersPage.validateMinerInList(minerIp2); + await minersPage.validateMinerInList(minerIp3); + await minersPage.validateAmountOfMiners(minerCount); + }); + }); + + test("UNPAIR - ADD all miners", async ({ minersPage, commonSteps, addMinersPage }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let originalMinerCount: number; + const allMinerIps: string[] = []; + + await test.step("Capture all miner IPs and select all miners", async () => { + originalMinerCount = await minersPage.getMinersCount(); + + for (let i = 0; i < originalMinerCount; i++) { + const minerIp = await minersPage.getMinerIpAddressByIndex(i); + allMinerIps.push(minerIp); + } + + await minersPage.clickSelectAllCheckbox(); + await minersPage.validateActionBarMinerCount(originalMinerCount); + }); + + await test.step("Unpair all miners", async () => { + await minersPage.clickActionsMenuButton(); + await minersPage.clickUnpairButton(); + await minersPage.clickUnpairConfirm(); + }); + + await test.step("Validate all miners were unpaired", async () => { + for (const minerIp of allMinerIps) { + await minersPage.validateMinerNotPresent(minerIp); + } + await minersPage.validateAmountOfMiners(0); + }); + + await test.step("Validate null state - no miners added", async () => { + await minersPage.validateTextIsVisible("You haven't paired any miners"); + await minersPage.validateTextIsVisible("Add miners to your fleet to get started."); + }); + + await test.step("Add all miners back using onboarding flow", async () => { + await minersPage.clickGetStarted(); + await addMinersPage.clickFindMinersInNetwork(); + await addMinersPage.clickContinueWithSelectedMiners(); + }); + + await test.step("Validate all miners were added back", async () => { + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinersAdded(originalMinerCount); + + for (const minerIp of allMinerIps) { + await minersPage.validateMinerInList(minerIp); + } + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersRename.spec.ts b/client/e2eTests/protoFleet/spec/minersRename.spec.ts new file mode 100644 index 000000000..112a5cf10 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersRename.spec.ts @@ -0,0 +1,434 @@ +import { testConfig } from "../config/test.config"; +import { expect, test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; + +const BULK_RENAME_PROPERTIES = [ + "custom", + "fixed-mac-address", + "fixed-serial-number", + "fixed-model", + "fixed-manufacturer", +] as const; + +const COUNTER_SCALE = { + MIN: 1, + MAX: 6, + DEFAULT: 2, +} as const; + +const COUNTER_START = { + DEFAULT: 1, + SINGLE_DIGIT: 5, + DOUBLE_DIGIT: 56, + TRIPLE_DIGIT: 567, +} as const; + +const CHARACTER_COUNT = { + MIN: 1, + MAX: 6, +} as const; + +const SEPARATORS_THAT_CHANGE_NAME = [ + { id: "dash", value: "-" }, + { id: "underscore", value: "_" }, + { id: "none", value: "" }, +] as const; + +const BULK_RENAME_COUNTER_PREVIEW = String(COUNTER_START.DEFAULT).padStart(COUNTER_SCALE.DEFAULT, "0"); + +test.describe("Miners Rename", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Rename back to just model names", async ({ browser }, testInfo) => { + // CLEANUP: Rename back to just model names + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + const page = await context.newPage(); + await page.goto("/"); + + try { + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, propertyId === "fixed-model"); + } + + await minersPage.clickBulkRenameSave(); + await minersPage.confirmBulkRenameWarningsIfPresent(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + } finally { + await context.close(); + } + }); + + test("Validate bulk rename functionality", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await minersPage.setBulkRenamePropertyOrder(BULK_RENAME_PROPERTIES); + + const minerCount = await minersPage.getMinersCount(); + + await test.step("Select all miners and open bulk rename", async () => { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + expect((await minersPage.getBulkRenamePropertyOrder())[0]).toBe("custom"); + }); + + await test.step("Enable all rename properties", async () => { + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, true); + } + + await minersPage.setCustomBulkRenameCounterScale(COUNTER_SCALE.DEFAULT); + }); + + await test.step("Select period separator", async () => { + await minersPage.selectBulkRenameSeparator("period"); + }); + + await test.step("Apply rename and wait for names update", async () => { + await minersPage.clickBulkRenameSave(); + await minersPage.waitForMinersTitle(); + await minersPage.waitForMinersListToLoad(); + + const expectedMinSegmentCount = BULK_RENAME_PROPERTIES.length - 1; + const expectedMaxSegmentCount = BULK_RENAME_PROPERTIES.length; + + await expect + .poll( + async () => { + const names = await minersPage.getMinerNames(); + return names.every((name) => { + const segments = name.split("."); + return ( + /^\d+$/.test(segments[0] ?? "") && + segments.length >= expectedMinSegmentCount && + segments.length <= expectedMaxSegmentCount + ); + }); + }, + { message: "Waiting for miner names to update with new format" }, + ) + .toBe(true); + }); + + await test.step("Validate renamed miner names", async () => { + const names = await minersPage.getMinerNames(); + expect(names).toHaveLength(minerCount); + + const expectedMinSegmentCount = BULK_RENAME_PROPERTIES.length - 1; + const expectedMaxSegmentCount = BULK_RENAME_PROPERTIES.length; + const counters: number[] = []; + + for (const name of names) { + const segments = name.split("."); + expect( + segments.length, + `Name should have between ${expectedMinSegmentCount} and ${expectedMaxSegmentCount} segments`, + ).toBeGreaterThanOrEqual(expectedMinSegmentCount); + expect( + segments.length, + `Name should have between ${expectedMinSegmentCount} and ${expectedMaxSegmentCount} segments`, + ).toBeLessThanOrEqual(expectedMaxSegmentCount); + + // Validate no empty segments + const emptySegmentIndices = segments.map((s, i) => (s.trim() === "" ? i : -1)).filter((i) => i >= 0); + expect( + emptySegmentIndices, + `Name "${name}" contains empty segments at positions: ${emptySegmentIndices.join(", ")}`, + ).toHaveLength(0); + + const counterSegment = segments[0]; + expect( + /^\d+$/.test(counterSegment), + `First segment should be numeric counter (validates 'custom' is first), got: "${counterSegment}" in "${name}"`, + ).toBe(true); + + const counter = parseInt(counterSegment, 10); + expect(counter, `Counter should be positive, got: ${counter}`).toBeGreaterThan(0); + counters.push(counter); + } + + const sortedCounters = [...counters].sort((a, b) => a - b); + const expectedSequence = Array.from({ length: minerCount }, (_, i) => i + 1); + expect(sortedCounters, "Counters should be sequential from 1 to N").toEqual(expectedSequence); + }); + }); + + test("Configure each miner rename property", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const minerCount = await minersPage.getMinersCount(); + expect(minerCount, "At least one miner must be available").toBeGreaterThan(0); + const minerName = await minersPage.getMinerNameByIndex(0); + const fixedProperties = BULK_RENAME_PROPERTIES.filter((p) => p !== "custom"); + const fixedPropertyValues = new Map<(typeof fixedProperties)[number], string>(); + let propertyOrder: string[] = []; + + await test.step("Open bulk rename for a single miner", async () => { + await minersPage.clickMinerCheckboxByIndex(0); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, true); + } + await minersPage.setCustomBulkRenameCounterScale(COUNTER_SCALE.DEFAULT); + propertyOrder = await minersPage.getBulkRenamePropertyOrder(); + }); + + await test.step("Capture fixed property preview values", async () => { + for (const propertyId of fixedProperties) { + await minersPage.clickBulkRenamePropertyOptions(propertyId); + fixedPropertyValues.set(propertyId, await minersPage.getFixedValuePreviewText()); + await minersPage.dismissRenameOptionsModal(); + } + }); + + const previewSegments = propertyOrder + .map((propertyId) => { + if (propertyId === "custom") { + return BULK_RENAME_COUNTER_PREVIEW; + } + + return fixedPropertyValues.get(propertyId as (typeof fixedProperties)[number]) ?? ""; + }) + .filter((segment) => segment.trim() !== ""); + + await test.step("Validate period separator preview behavior", async () => { + await minersPage.selectBulkRenameSeparator("period"); + const expectedPeriodPreviewName = previewSegments.join("."); + await minersPage.validateBulkRenamePreviewState(expectedPeriodPreviewName, minerName); + }); + + await test.step("Validate other separators update the new name", async () => { + for (const separator of SEPARATORS_THAT_CHANGE_NAME) { + await minersPage.selectBulkRenameSeparator(separator.id); + const expectedPreviewName = previewSegments.join(separator.value); + await minersPage.waitForBulkRenamePreviewName(expectedPreviewName); + } + }); + + await test.step("Toggle all properties off except custom", async () => { + for (const propertyId of BULK_RENAME_PROPERTIES) { + await minersPage.toggleBulkRenameProperty(propertyId, propertyId === "custom"); + } + }); + + await test.step("Validate custom property options preview behavior", async () => { + await minersPage.clickBulkRenamePropertyOptions("custom"); + + // Make the initial expectations deterministic. + await minersPage.selectCustomPropertyType("string-and-counter"); + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.DEFAULT); + await minersPage.clickCustomPropertyCounterScale(COUNTER_SCALE.DEFAULT); + + await minersPage.fillCustomPropertyPrefix("pre"); + await minersPage.validateCustomPropertyPreviewText("pre01"); + + await minersPage.fillCustomPropertyPrefix(""); + await minersPage.fillCustomPropertySuffix("suf"); + await minersPage.validateCustomPropertyPreviewText("01suf"); + + await minersPage.fillCustomPropertyPrefix("pre"); + await minersPage.validateCustomPropertyPreviewText("pre01suf"); + + await minersPage.fillCustomPropertyCounterStart(""); + await minersPage.validateCustomPropertySaveDisabled(); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.SINGLE_DIGIT); + await minersPage.validateCustomPropertyPreviewText("pre05suf"); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.DOUBLE_DIGIT); + await minersPage.validateCustomPropertyPreviewText("pre56suf"); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.TRIPLE_DIGIT); + await minersPage.validateCustomPropertyPreviewText("pre567suf"); + + await minersPage.fillCustomPropertyCounterStart(COUNTER_START.SINGLE_DIGIT); + + for (let scale = COUNTER_SCALE.MIN; scale <= COUNTER_SCALE.MAX; scale++) { + await minersPage.clickCustomPropertyCounterScale(scale); + const paddedCounterValue = String(COUNTER_START.SINGLE_DIGIT).padStart(scale, "0"); + await minersPage.validateCustomPropertyPreviewText(`pre${paddedCounterValue}suf`); + } + + await minersPage.clickCustomPropertyCounterScale(COUNTER_SCALE.MIN); + await minersPage.selectCustomPropertyType("counter-only"); + await minersPage.validateCustomPropertyPreviewText(String(COUNTER_START.SINGLE_DIGIT)); + + await minersPage.selectCustomPropertyType("string-only"); + await minersPage.fillCustomPropertyStringValue("sometext"); + await minersPage.validateCustomPropertyPreviewText("sometext"); + + await minersPage.dismissRenameOptionsModal(); + await minersPage.toggleBulkRenameProperty("custom", false); + }); + + await test.step("Validate fixed property options preview behavior", async () => { + for (const propertyId of fixedProperties) { + const fullValue = fixedPropertyValues.get(propertyId) ?? ""; + + await minersPage.toggleBulkRenameProperty(propertyId, true); + await minersPage.clickBulkRenamePropertyOptions(propertyId); + + await minersPage.validateFixedValuePreviewText(fullValue); + + // String section options only render when character count is not "All". + await minersPage.clickFixedValueCharacterCountOption(CHARACTER_COUNT.MIN); + + await minersPage.clickFixedValueStringSectionOption("first"); + for (let count = CHARACTER_COUNT.MIN; count <= CHARACTER_COUNT.MAX; count++) { + await minersPage.clickFixedValueCharacterCountOption(count); + await minersPage.validateFixedValuePreviewText(fullValue.slice(0, count)); + } + + await minersPage.clickFixedValueStringSectionOption("last"); + for (let count = CHARACTER_COUNT.MIN; count <= CHARACTER_COUNT.MAX; count++) { + await minersPage.clickFixedValueCharacterCountOption(count); + await minersPage.validateFixedValuePreviewText(fullValue.slice(-count)); + } + + await minersPage.dismissRenameOptionsModal(); + await minersPage.toggleBulkRenameProperty(propertyId, false); + } + }); + }); + + test("RENAME a single miner", async ({ minersPage, page, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/RenameMiners/); + const responsePromise = page.waitForResponse(/RenameMiners/); + + const newName = generateRandomText("Renamed Miner E2E"); + let minerIp: string; + + await test.step("Select first miner and rename it", async () => { + minerIp = await minersPage.getMinerIpAddressByIndex(0); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickRenameButton(); + await minersPage.fillRenameInput(newName); + await minersPage.clickRenameSave(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Miner renamed"); + }); + + await test.step("Validate 'RenameMiners' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody).toHaveProperty("deviceSelector"); + test.expect(requestBody.deviceSelector).toHaveProperty("includeDevices"); + test.expect(requestBody.deviceSelector.includeDevices).toHaveProperty("deviceIdentifiers"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(1); + test.expect(response.status()).toBe(200); + }); + + await test.step("Validate name updated in miner list", async () => { + await minersPage.validateMinerName(minerIp, newName); + }); + }); + + test("BULK RENAME multiple miners", async ({ minersPage, page, commonSteps }, testInfo) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(testInfo.project.use?.isMobile === true, "Desktop-only bulk rename flow"); + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + const requestPromise = page.waitForRequest(/RenameMiners/); + const responsePromise = page.waitForResponse(/RenameMiners/); + + let minerIp1: string; + let minerIp2: string; + + await test.step("Select two rig miners and open bulk rename", async () => { + await minersPage.filterRigMiners(); + minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.validateActionBarMinerCount(1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.validateActionBarMinerCount(2); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + }); + + await test.step("Enable MAC address and validate preview updates", async () => { + await minersPage.clickBulkRenamePropertyToggle("fixed-mac-address"); + await test.expect(page.getByTestId("bulk-rename-desktop-preview")).toContainText(/([0-9a-f]{2}:){2}/i); + }); + + await test.step("Save the bulk rename", async () => { + await minersPage.clickBulkRenameSave(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Renamed 2 miners"); + }); + + await test.step("Validate 'RenameMiners' API request", async () => { + const request = await requestPromise; + const response = await responsePromise; + const requestBody = request.postDataJSON(); + test.expect(request.method()).toBe("POST"); + test.expect(requestBody.deviceSelector.includeDevices.deviceIdentifiers).toHaveLength(2); + test.expect(requestBody.nameConfig.properties).toHaveLength(1); + test.expect(requestBody.nameConfig.separator).toBe("-"); + test.expect(response.status()).toBe(200); + }); + }); + + test("BULK RENAME mobile layout", async ({ minersPage, page, commonSteps }, testInfo) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(testInfo.project.use?.isMobile !== true, "Mobile-only bulk rename layout"); + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Open bulk rename from selected miners", async () => { + await minersPage.filterRigMiners(); + const minerIp1 = await minersPage.getMinerIpAddressByIndex(0); + const minerIp2 = await minersPage.getMinerIpAddressByIndex(1); + await minersPage.clickMinerCheckbox(minerIp1); + await minersPage.clickMinerCheckbox(minerIp2); + await minersPage.clickActionsMenuButton(); + await minersPage.clickRenameButton(); + await minersPage.validateBulkRenamePageOpened(); + }); + + await test.step("Validate mobile preview and fixed-value options sheet", async () => { + await test.expect(page.getByTestId("bulk-rename-mobile-preview")).toBeVisible(); + await minersPage.clickBulkRenamePropertyToggle("fixed-mac-address"); + await minersPage.clickBulkRenamePropertyOptions("fixed-mac-address"); + await minersPage.validateTextIsVisible("Number of characters"); + await test.expect(page.getByTestId("fixed-value-options-save-button-mobile")).toBeVisible(); + await page.getByTestId("fixed-value-options-save-button-mobile").click(); + await minersPage.validateBulkRenamePageOpened(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/minersSleepWake.spec.ts b/client/e2eTests/protoFleet/spec/minersSleepWake.spec.ts new file mode 100644 index 000000000..fae959705 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/minersSleepWake.spec.ts @@ -0,0 +1,194 @@ +/* eslint-disable playwright/expect-expect */ +import { expect } from "@playwright/test"; +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; + +async function ensureVisibleMinersAwake(minersPage: MinersPage) { + const hasSleepingMiners = await minersPage.hasAnyMinerWithStatus("Sleeping"); + const hasWakingMiners = await minersPage.hasAnyMinerWithStatus("Waking"); + + if (!hasSleepingMiners && !hasWakingMiners) { + return; + } + + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + await minersPage.validateNoMinerWithStatus("Sleeping"); + await minersPage.validateNoMinerWithStatus("Waking"); +} + +test.describe("Miners SLEEP - WAKE actions", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: wake up miners", async ({ browser }, testInfo) => { + if (testConfig.target === "real") { + return; + } + + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + await ensureVisibleMinersAwake(minersPage); + } finally { + await context.close(); + } + }); + + test("SLEEP - WAKE a miner", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto rig miners", async () => { + await minersPage.filterRigMiners(); + await ensureVisibleMinersAwake(minersPage); + }); + + let minerIp: string; + + await test.step("Select first miner and shut it down", async () => { + minerIp = await minersPage.getMinerIpAddressByStatus("Hashing"); + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickShutdownButton(); + await minersPage.clickShutdownConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Putting miners to sleep"); + await minersPage.validateTextInToastGroup("Put 1 out of 1 miners to sleep"); + }); + + await test.step("Validate miner is sleeping", async () => { + await minersPage.validateMinerStatusSettled(minerIp, "Sleeping"); + }); + + await test.step("Select all miners and wake them up", async () => { + await minersPage.clickMinerThreeDotsButton(minerIp); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + }); + + await test.step("Validate update process", async () => { + await minersPage.validateTextInToastGroup("Waking up miners"); + await minersPage.validateTextInToastGroup(`Woke up 1 out of 1 miners`); + }); + + await test.step("Validate none of the miners are sleeping", async () => { + await minersPage.validateMinerStatusSettled(minerIp, "Hashing"); + await minersPage.validateNoMinerWithStatus("Sleeping"); + await minersPage.validateNoMinerWithStatus("Waking"); + }); + }); + + test("SLEEP - WAKE all rig miners, without page refresh", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + await test.step("Filter Proto rig miners", async () => { + await minersPage.filterRigMiners(); + await ensureVisibleMinersAwake(minersPage); + }); + + let minerCount: number; + + await test.step("Select all miners and put them to sleep", async () => { + await minersPage.clickSelectAllCheckbox(); + minerCount = await minersPage.getMinersCount(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickShutdownButton(); + await minersPage.clickShutdownConfirm(); + }); + + await test.step("Validate sleep process", async () => { + await minersPage.validateTextInToastGroup("Putting miners to sleep"); + await minersPage.validateTextInToastGroup(`Put ${minerCount} out of ${minerCount} miners to sleep`); + }); + + await test.step("Validate all miners are sleeping", async () => { + await minersPage.validateAllMinersStatusSettled("Sleeping"); + }); + + await test.step("Select all miners and wake them up", async () => { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + }); + + await test.step("Validate wake up process", async () => { + await minersPage.validateTextInToastGroup("Waking up miners"); + await minersPage.validateTextInToastGroup(`Woke up ${minerCount} out of ${minerCount} miners`); + }); + + await test.step("Validate all miners are awake", async () => { + await minersPage.validateAllMinersStatusSettled("Hashing"); + }); + }); + + test("SLEEP - WAKE all non-rig miners, without page refresh", async ({ minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + await commonSteps.goToMinersPage(); + + let initialMinerStatuses: Array<{ ipAddress: string; status: string }>; + + await test.step("Filter all miners except Proto Rig", async () => { + await minersPage.filterAllMinersExceptRig(); + await ensureVisibleMinersAwake(minersPage); + initialMinerStatuses = await minersPage.getVisibleMinerStatuses(); + }); + + let minerCount: number; + + await test.step("Select all non-rig miners and put them to sleep", async () => { + minerCount = initialMinerStatuses.length; + expect(minerCount).toBeGreaterThan(0); + + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickShutdownButton(); + await minersPage.clickShutdownConfirm(); + }); + + await test.step("Validate sleep process", async () => { + await minersPage.validateTextInToastGroup("Putting miners to sleep"); + await minersPage.validateTextInToastGroup(`Put ${minerCount} out of ${minerCount} miners to sleep`); + }); + + await test.step("Validate all non-rig miners are sleeping", async () => { + await minersPage.validateAllMinersStatusSettled("Sleeping"); + }); + + await test.step("Select all non-rig miners and wake them up", async () => { + await minersPage.clickSelectAllCheckbox(); + await minersPage.clickActionsMenuButton(); + await minersPage.clickWakeUpButton(); + await minersPage.clickWakeUpConfirm(); + }); + + await test.step("Validate wake up process", async () => { + await minersPage.validateTextInToastGroup("Waking up miners"); + await minersPage.validateTextInToastGroup(`Woke up ${minerCount} out of ${minerCount} miners`); + }); + + await test.step("Validate all non-rig miners returned to their initial statuses", async () => { + for (const miner of initialMinerStatuses) { + await minersPage.validateMinerStatusSettled(miner.ipAddress, miner.status); + } + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/navigation.spec.ts b/client/e2eTests/protoFleet/spec/navigation.spec.ts new file mode 100644 index 000000000..68e1854aa --- /dev/null +++ b/client/e2eTests/protoFleet/spec/navigation.spec.ts @@ -0,0 +1,110 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; + +test.describe("Navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Overview navigation", async ({ homePage, minersPage, commonSteps }) => { + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to control board issues", async () => { + await homePage.clickControlBoardsLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("Control board issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + + await test.step("Navigate to fan issues", async () => { + await homePage.clickFansLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("Fan issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + + await test.step("Navigate to hashboard issues", async () => { + await homePage.clickHashboardsLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("Hash board issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + + await test.step("Navigate to power supply issues", async () => { + await homePage.clickPowerSuppliesLink(); + await minersPage.validateMinersPageOpened(); + await minersPage.validateActiveFilter("PSU issue"); + }); + + await test.step("Navigate back to overview", async () => { + await homePage.navigateToHomePage(); + await homePage.validateHomePageOpened(); + }); + }); + + test("Navigate between main pages and settings sub-pages", async ({ authPage, settingsPage }) => { + await test.step("Log in as admin user", async () => { + await authPage.inputUsername(testConfig.users.admin.username); + await authPage.inputPassword(testConfig.users.admin.password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate from Home to Settings page", async () => { + await authPage.navigateToSettingsPage(); + }); + + await test.step("Navigate from Settings to Team Settings", async () => { + await settingsPage.navigateToTeamSettings(); + }); + + await test.step("Navigate from Team Settings back to Settings page", async () => { + await settingsPage.navigateToSettingsPage(); + }); + + await test.step("Navigate from Settings to Home page", async () => { + await settingsPage.navigateToHomePage(); + }); + + await test.step("Navigate from Home to Team Settings", async () => { + await settingsPage.navigateToTeamSettings(); + }); + + await test.step("Navigate from Team Settings back to Settings page", async () => { + await settingsPage.navigateToSettingsPage(); + }); + + await test.step("Navigate from Settings to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Navigate from Security Settings to Mining Pools Settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + }); + + await test.step("Navigate from Mining Pools Settings to Miners page", async () => { + await settingsPage.navigateToMinersPage(); + }); + + await test.step("Navigate from Miners page back to Mining Pools Settings", async () => { + await settingsPage.navigateToMiningPoolsSettings(); + }); + + await test.step("Navigate from Mining Pools Settings to Home page", async () => { + await settingsPage.navigateToHomePage(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/racks.spec.ts b/client/e2eTests/protoFleet/spec/racks.spec.ts new file mode 100644 index 000000000..ef5d6651b --- /dev/null +++ b/client/e2eTests/protoFleet/spec/racks.spec.ts @@ -0,0 +1,578 @@ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { type RackSelectorMiner, RacksPage } from "../pages/racks"; + +const AUTOMATION_ZONE = "AutomationZone"; +const RACK_COLUMNS = 2; +const RACK_ROWS = 2; +const VALIDATION_RACK_COLUMNS = 1; +const VALIDATION_RACK_ROWS = 1; +const NETWORK_RACK_COLUMNS = 9; +const NETWORK_RACK_ROWS = 9; +const LARGE_RACK_COLUMNS = 3; +const LARGE_RACK_ROWS = 3; +const OVERVIEW_RACK_COLUMNS = 8; +const OVERVIEW_RACK_ROWS = 2; +const ORDER_INDEX_SCENARIOS = [ + { label: "Bottom left", expectedNumbers: [3, 4, 1, 2] }, + { label: "Top left", expectedNumbers: [1, 2, 3, 4] }, + { label: "Bottom right", expectedNumbers: [4, 3, 2, 1] }, + { label: "Top right", expectedNumbers: [2, 1, 4, 3] }, +] as const; + +test.describe("Racks", () => { + test.beforeEach(async ({ page, commonSteps, racksPage }) => { + await page.goto("/"); + await commonSteps.loginAsAdmin(); + await racksPage.navigateToRacksPage(); + }); + + test.afterEach("CLEANUP: Delete all racks", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ + baseURL: testConfig.baseUrl, + viewport: testInfo.project.use?.viewport, + }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const racksPage = new RacksPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await racksPage.navigateToRacksPage(); + await cleanupAllRacks(racksPage); + } finally { + await context.close(); + } + }); + + async function cleanupAllRacks(racksPage: RacksPage) { + await racksPage.navigateToRacksPage(); + await racksPage.tryAction(() => racksPage.clickViewList()); + await racksPage.waitForRackListToLoad(); + + let rackNames = await racksPage.listRackNames(); + + while (rackNames.length > 0) { + await racksPage.openRackFromList(rackNames[0]); + await racksPage.clickEditRack(); + await racksPage.clickDeleteRack(); + await racksPage.clickDeleteConfirm(); + await racksPage.tryAction(() => racksPage.validateRackDeletedToast()); + + await racksPage.navigateToRacksPage(); + await racksPage.tryAction(() => racksPage.clickViewList()); + await racksPage.waitForRackListToLoad(); + rackNames = await racksPage.listRackNames(); + } + } + + function createZoneName(prefix: "A" | "B") { + const suffix = Math.random() + .toString(36) + .replace(/[^a-z]+/g, "") + .slice(0, 6); + return `${prefix}-${suffix || "zone"}`; + } + + async function addSelectableMinersToSlots( + racksPage: RacksPage, + minerCount: number, + slotNumbers: readonly number[], + ): Promise { + test.expect(slotNumbers).toHaveLength(minerCount); + + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + const selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(minerCount); + const selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + await racksPage.selectMinersInSelectorByIndex(selectableMinerIndexes); + await racksPage.clickContinueInMinerSelector(); + + for (let i = 0; i < selectedMiners.length; i++) { + await racksPage.selectRackMiner(selectedMiners[i].ipAddress); + await racksPage.clickRackSlot(slotNumbers[i]); + } + + return selectedMiners; + } + + async function expectGridRackLabels(racksPage: RacksPage, expectedLabels: string[]) { + await test.expect.poll(async () => await racksPage.getGridRackLabels()).toEqual(expectedLabels); + } + + async function expectListRackLabels(racksPage: RacksPage, expectedLabels: string[]) { + await test.expect.poll(async () => await racksPage.listRackNames()).toEqual(expectedLabels); + } + + test("Create rack with miners assigned by name", async ({ racksPage }) => { + let rackLabel = ""; + let orderIndexValue = ""; + let selectedMiners: RackSelectorMiner[] = []; + + await test.step("Create a new 2x2 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(rackLabel).toBeTruthy(); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + + orderIndexValue = await racksPage.getOrderIndexValue(); + await racksPage.clickContinueFromRackSettings(); + }); + + await test.step("Validate empty rack assignment state", async () => { + await racksPage.validateRackConfiguration(RACK_COLUMNS, RACK_ROWS, orderIndexValue); + await racksPage.validateAssignedMinersCount(0, 4); + }); + + await test.step("Add the first two miners", async () => { + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + selectedMiners = await racksPage.getMinersFromSelector([0, 1]); + test.expect(selectedMiners).toHaveLength(2); + await racksPage.selectMinersInSelectorByIndex([0, 1]); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign miners by name and validate positions", async () => { + await racksPage.clickAssignByName(); + await racksPage.validateMinersAssignedByName(selectedMiners); + }); + + await test.step("Save rack and validate rack grid card", async () => { + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible(rackLabel, AUTOMATION_ZONE); + await racksPage.validateRackCardGrid(rackLabel, AUTOMATION_ZONE, RACK_COLUMNS, RACK_ROWS); + }); + + await test.step("Validate rack in list view", async () => { + await racksPage.clickViewList(); + await racksPage.waitForRackListToLoad({ allowEmpty: false }); + await racksPage.validateRackRow(rackLabel, AUTOMATION_ZONE, 2); + }); + }); + + test("Rack numbering updates when order index changes", async ({ racksPage }) => { + let selectedMiners: RackSelectorMiner[] = []; + + await test.step("Create a new 2x2 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + }); + + await test.step("Add four miners", async () => { + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + selectedMiners = await racksPage.getMinersFromSelector([0, 1, 2, 3]); + test.expect(selectedMiners).toHaveLength(4); + await racksPage.selectMinersInSelectorByIndex([0, 1, 2, 3]); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign miners manually in DOM order and validate default numbering", async () => { + await racksPage.clickAssignManually(); + await racksPage.assignMinersToSlotsInDomOrder(selectedMiners); + + await racksPage.validateRackSlotNumbersInDomOrder(ORDER_INDEX_SCENARIOS[0].expectedNumbers); + await racksPage.validateMinerPositions(selectedMiners, ORDER_INDEX_SCENARIOS[0].expectedNumbers); + }); + + for (const scenario of ORDER_INDEX_SCENARIOS.slice(1)) { + await test.step(`Change order index to ${scenario.label}`, async () => { + await racksPage.clickEditRackSettings(); + await racksPage.changeOrderIndexAndContinue(scenario.label); + await racksPage.validateRackConfiguration(RACK_COLUMNS, RACK_ROWS, scenario.label); + await racksPage.validateRackSlotNumbersInDomOrder(scenario.expectedNumbers); + await racksPage.validateMinerPositions(selectedMiners, scenario.expectedNumbers); + }); + } + }); + + test("Manual rack assignment supports search, selection replacement, and saved slot state", async ({ racksPage }) => { + let rackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + let selectableMinerIndexes: number[] = []; + + await test.step("Create a new 3x3 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(rackLabel).toBeTruthy(); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(LARGE_RACK_COLUMNS); + await racksPage.inputRows(LARGE_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + }); + + await test.step("Manage miners and add the first miner to the rack list", async () => { + await racksPage.clickManageMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); + selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + test.expect(selectedMiners).toHaveLength(2); + await racksPage.selectMinersInSelectorByIndex([selectableMinerIndexes[0]]); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Search and assign the second miner to slot 04", async () => { + await racksPage.clickRackSlot(4); + await racksPage.clickRackSlotMenuItem("Search miners"); + await racksPage.assignSearchMinerByIpAddress(selectedMiners[1].ipAddress); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); + await racksPage.validateRackSlotsHighlighted([4]); + }); + + await test.step("Open the assigned slot while the first miner is selected", async () => { + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + await racksPage.clickRackSlot(4); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 4); + await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); + }); + + await test.step("Replace slot 04 assignment from the list", async () => { + await racksPage.clickRackSlotMenuItem("Select from list"); + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); + await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); + await racksPage.validateRackSlotsHighlighted([4]); + }); + + await test.step("Assign the second miner to slot 06", async () => { + await racksPage.selectRackMiner(selectedMiners[1].ipAddress); + await racksPage.clickRackSlot(6); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 4); + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 6); + await racksPage.validateRackSlotsHighlighted([4, 6]); + }); + + await test.step("Clear assignments and validate empty state", async () => { + await racksPage.clickClearAssignments(); + + await racksPage.validateMinerRowUnassigned(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowUnassigned(selectedMiners[1].ipAddress); + await racksPage.validateRackSlotsNotHighlighted([4, 6]); + }); + + await test.step("Assign miners to slots 01 and 09 and save", async () => { + await racksPage.selectRackMiner(selectedMiners[0].ipAddress); + await racksPage.clickRackSlot(1); + await racksPage.selectRackMiner(selectedMiners[1].ipAddress); + await racksPage.clickRackSlot(9); + + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[0].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[0].ipAddress, 1); + await racksPage.validateMinerRowHasGreenCheck(selectedMiners[1].ipAddress); + await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 9); + await racksPage.validateRackSlotsHighlighted([1, 9]); + + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel); + }); + + await test.step("Open the created rack and validate saved slots", async () => { + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.validateRackOverviewAssignedSlots([1, 9]); + await racksPage.validateRackOverviewEmptySlots([2, 3, 4, 5, 6, 7, 8]); + }); + }); + + test("Rack overview search assignment updates slots and miners filter state", async ({ racksPage, minersPage }) => { + let rackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + let selectableMinerIndexes: number[] = []; + let expectedVisibleMinerCount = 0; + + await test.step("Create and save a new 8x2 rack", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + + rackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(rackLabel).toBeTruthy(); + + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(OVERVIEW_RACK_COLUMNS); + await racksPage.inputRows(OVERVIEW_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel); + }); + + await test.step("Open the created rack and assign the first miner to slot 02", async () => { + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.clickRackOverviewEmptySlot(2); + await racksPage.waitForMinerSelectorListToLoad(); + expectedVisibleMinerCount = (await racksPage.getAllVisibleMinersFromSelector()).length; + + selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); + selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + test.expect(selectedMiners).toHaveLength(2); + + await racksPage.assignSearchMinerByIpAddress(selectedMiners[0].ipAddress); + await racksPage.validateRackOverviewAssignedSlots([2]); + }); + + await test.step("Reassign the same first miner from slot 02 to slot 15", async () => { + await racksPage.clickRackOverviewEmptySlot(15); + await racksPage.assignSearchMinerByIpAddress(selectedMiners[0].ipAddress); + + await racksPage.validateRackOverviewAssignedSlots([15]); + await racksPage.validateRackOverviewEmptySlots([2]); + }); + + await test.step("Assign the second miner to slot 02", async () => { + await racksPage.clickRackOverviewEmptySlot(2); + await racksPage.assignSearchMinerByIpAddress(selectedMiners[1].ipAddress); + + await racksPage.validateRackOverviewAssignedSlots([2, 15]); + }); + + await test.step("Validate miners page is filtered to the rack and contains only the assigned miners", async () => { + await racksPage.clickViewMiners(); + await minersPage.validateActiveFilter(rackLabel); + await minersPage.validateAmountOfMiners(2); + await minersPage.validateMinerInList(selectedMiners[0].ipAddress); + await minersPage.validateMinerInList(selectedMiners[1].ipAddress); + }); + + await test.step("Remove all rack miners from edit rack manage miners flow", async () => { + await racksPage.navigateToRacksPage(); + await racksPage.clickViewGrid(); + await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); + await racksPage.clickEditRack(); + await racksPage.clickManageMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[0].ipAddress); + await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[1].ipAddress); + await racksPage.clickContinueInMinerSelector(); + await racksPage.validateTextIsVisible("No miners added to this rack yet."); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(rackLabel, "updated"); + }); + + await test.step("Validate rack overview is empty after saving", async () => { + await racksPage.validateRackOverviewEmptySlots([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + }); + + await test.step("Validate miners view empty state and clear the rack filter", async () => { + await racksPage.clickViewMiners(); + await minersPage.validateNoResultsEmptyState(); + await minersPage.clickClearAllFilters(); + await minersPage.validateActiveFilterNotVisible(rackLabel); + await minersPage.waitForMinersListToLoad(); + await minersPage.validateMinersAdded(expectedVisibleMinerCount); + }); + }); + + test("Multiple racks support zone filtering and miner sorting", async ({ racksPage }) => { + const zoneA = createZoneName("A"); + const zoneB = createZoneName("B"); + const createdRackLabels: string[] = []; + + await test.step("Create rack A-01 with three miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneA); + test.expect(await racksPage.getGeneratedRackLabel()).toBe("A-01"); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(RACK_COLUMNS); + await racksPage.inputRows(RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + await addSelectableMinersToSlots(racksPage, 3, [1, 2, 3]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast("A-01"); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("A-01", zoneA); + createdRackLabels.push("A-01"); + }); + + await test.step("Create rack A-02 with two miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneA); + test.expect(await racksPage.getGeneratedRackLabel()).toBe("A-02"); + await racksPage.clickContinueFromRackSettings(); + await addSelectableMinersToSlots(racksPage, 2, [1, 2]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast("A-02"); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("A-02", zoneA); + createdRackLabels.push("A-02"); + }); + + await test.step("Create rack B-01 with one miner", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(zoneB); + test.expect(await racksPage.getGeneratedRackLabel()).toBe("B-01"); + await racksPage.clickContinueFromRackSettings(); + await addSelectableMinersToSlots(racksPage, 1, [1]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast("B-01"); + await racksPage.clickViewGrid(); + await racksPage.validateRackCardVisible("B-01", zoneB); + createdRackLabels.push("B-01"); + }); + + await test.step("Filter racks by zone in grid view", async () => { + await racksPage.applyZoneFilter([zoneA]); + await expectGridRackLabels(racksPage, ["A-01", "A-02"]); + + await racksPage.applyZoneFilter([zoneB]); + await expectGridRackLabels(racksPage, ["B-01"]); + + await racksPage.toggleAllZoneFilters(); + await expectGridRackLabels(racksPage, createdRackLabels); + + await racksPage.toggleAllZoneFilters(); + }); + + await test.step("Filter racks by zone in list view", async () => { + await racksPage.clickViewList(); + + await racksPage.applyZoneFilter([zoneA]); + await expectListRackLabels(racksPage, ["A-01", "A-02"]); + + await racksPage.applyZoneFilter([zoneB]); + await expectListRackLabels(racksPage, ["B-01"]); + + await racksPage.toggleAllZoneFilters(); + await expectListRackLabels(racksPage, createdRackLabels); + + await racksPage.toggleAllZoneFilters(); + await racksPage.clickViewGrid(); + }); + + await test.step("Validate default grid order and miners sort order", async () => { + await expectGridRackLabels(racksPage, ["A-01", "A-02", "B-01"]); + await racksPage.selectGridSort("Miners"); + await expectGridRackLabels(racksPage, ["B-01", "A-02", "A-01"]); + }); + }); + + test("Assign by network orders all miners by IP address on a 9x9 rack", async ({ racksPage }) => { + let allVisibleMiners: RackSelectorMiner[] = []; + + await test.step("Create a new 9x9 rack and add all visible miners", async () => { + await racksPage.clickAddRackButton(); + await racksPage.inputZone(AUTOMATION_ZONE); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(NETWORK_RACK_COLUMNS); + await racksPage.inputRows(NETWORK_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + allVisibleMiners = await racksPage.getAllVisibleMinersFromSelector(); + test.expect(allVisibleMiners.length).toBeGreaterThan(0); + test.expect(allVisibleMiners.length).toBeLessThanOrEqual(NETWORK_RACK_COLUMNS * NETWORK_RACK_ROWS); + await racksPage.clickSelectAllMinersInSelector(); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign all miners by network and validate positions by IP and name", async () => { + await racksPage.clickAssignByNetwork(); + await racksPage.validateMinersAssignedByNetwork(allVisibleMiners); + }); + }); + + test("Rack settings validation blocks invalid input and miner overflow until corrected", async ({ racksPage }) => { + const validationZone = createZoneName("A"); + let generatedRackLabel = ""; + let selectedMiners: RackSelectorMiner[] = []; + + await test.step("Validate required zone before continuing", async () => { + await racksPage.clickAddRackButton(); + await racksPage.clickContinueFromRackSettings(); + await racksPage.validateRackSettingsFieldError("rack-zone", "A zone is required"); + await racksPage.validateTitleInModal("Rack settings"); + }); + + await test.step("Validate required label and invalid dimensions", async () => { + await racksPage.inputZone(validationZone); + generatedRackLabel = await racksPage.getGeneratedRackLabel(); + test.expect(generatedRackLabel).toBe("A-01"); + + await racksPage.inputRackLabel(""); + await racksPage.enableCustomRackLayout(); + await racksPage.inputColumns(0); + await racksPage.inputRows(13); + await racksPage.clickContinueFromRackSettings(); + + await racksPage.validateRackSettingsFieldError("rack-label", "A label is required"); + await racksPage.validateRackSettingsFieldError("rack-columns", "Columns must be a whole number between 1 and 12"); + await racksPage.validateRackSettingsFieldError("rack-rows", "Rows must be a whole number between 1 and 12"); + await racksPage.validateTitleInModal("Rack settings"); + }); + + await test.step("Correct rack settings and continue", async () => { + await racksPage.inputRackLabel(generatedRackLabel); + await racksPage.inputColumns(VALIDATION_RACK_COLUMNS); + await racksPage.inputRows(VALIDATION_RACK_ROWS); + await racksPage.clickContinueFromRackSettings(); + + await racksPage.validateRackConfiguration(VALIDATION_RACK_COLUMNS, VALIDATION_RACK_ROWS, "Bottom left"); + await racksPage.validateAssignedMinersCount(0, 1); + }); + + await test.step("Validate miner overflow error and recover", async () => { + await racksPage.clickAddMiners(); + await racksPage.waitForMinerSelectorListToLoad(); + + const selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); + selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); + await racksPage.selectMinersInSelectorByIndex(selectableMinerIndexes); + await racksPage.clickContinueInMinerSelector(); + + await racksPage.validateMinerSelectorOverflowError(2, 1); + await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[1].ipAddress); + await racksPage.clickContinueInMinerSelector(); + }); + + await test.step("Assign remaining miner and save the rack", async () => { + await racksPage.clickAssignByNetwork(); + await racksPage.validateMinersAssignedByNetwork([selectedMiners[0]]); + await racksPage.clickSaveRack(); + await racksPage.validateRackToast(generatedRackLabel); + await racksPage.validateRackCardVisible(generatedRackLabel, validationZone); + await racksPage.validateRackCardGrid( + generatedRackLabel, + validationZone, + VALIDATION_RACK_COLUMNS, + VALIDATION_RACK_ROWS, + ); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts b/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts new file mode 100644 index 000000000..20bbed083 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/schedulesSettings.spec.ts @@ -0,0 +1,132 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomText } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsSchedulesPage } from "../pages/settingsSchedules"; + +const SCHEDULE_PREFIX = "schedule_e2e"; + +test.describe("Proto Fleet - Schedules", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterEach("CLEANUP: Delete schedules created during tests", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const viewport = testInfo.project.use?.viewport; + const context = await browser.newContext({ baseURL: testConfig.baseUrl, viewport }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsSchedulesPage = new SettingsSchedulesPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + await settingsSchedulesPage.navigateToSchedulesSettings(); + await settingsSchedulesPage.deleteSchedulesByPrefix(SCHEDULE_PREFIX); + } finally { + await context.close(); + } + }); + + test("Create, pause/resume, edit, and delete a schedule", async ({ commonSteps, settingsSchedulesPage }) => { + const scheduleName = generateRandomText(SCHEDULE_PREFIX); + const updatedScheduleName = `${scheduleName}_updated`; + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to schedules settings", async () => { + await settingsSchedulesPage.navigateToSchedulesSettings(); + await settingsSchedulesPage.validateSchedulesPageOpened(); + }); + + await test.step("Create a one-time schedule for one miner", async () => { + await settingsSchedulesPage.clickAddSchedule(); + await settingsSchedulesPage.inputScheduleName(scheduleName); + await settingsSchedulesPage.selectStartDate(1); + await settingsSchedulesPage.openMinersTargetSelector(); + await settingsSchedulesPage.waitForMinerSelectionModalToLoad(); + await settingsSchedulesPage.selectFirstMiners(1); + await settingsSchedulesPage.confirmMinerSelection(); + await settingsSchedulesPage.clickSaveSchedule(); + }); + + await test.step("Validate the schedule was created", async () => { + await settingsSchedulesPage.validateScheduleVisible(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + await settingsSchedulesPage.validateScheduleAction(scheduleName, "Set power target"); + await settingsSchedulesPage.validateScheduleTargetSummary(scheduleName, "Applies to 1 miner"); + }); + + await test.step("Pause and resume the schedule", async () => { + await settingsSchedulesPage.pauseSchedule(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Paused"); + + await settingsSchedulesPage.resumeSchedule(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + }); + + await test.step("Edit the schedule name", async () => { + await settingsSchedulesPage.openEditSchedule(scheduleName); + await settingsSchedulesPage.inputScheduleName(updatedScheduleName); + await settingsSchedulesPage.clickSaveSchedule(); + await settingsSchedulesPage.validateScheduleVisible(updatedScheduleName); + await settingsSchedulesPage.validateScheduleNotVisible(scheduleName); + }); + + await test.step("Delete the schedule", async () => { + await settingsSchedulesPage.deleteSchedule(updatedScheduleName); + }); + }); + + test("Recurring schedule validation", async ({ commonSteps, settingsSchedulesPage }) => { + const scheduleName = generateRandomText(SCHEDULE_PREFIX); + + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to schedules settings", async () => { + await settingsSchedulesPage.navigateToSchedulesSettings(); + await settingsSchedulesPage.validateSchedulesPageOpened(); + }); + + await test.step("Switch to a recurring weekly schedule and validate days are required", async () => { + await settingsSchedulesPage.clickAddSchedule(); + await settingsSchedulesPage.inputScheduleName(scheduleName); + await settingsSchedulesPage.selectScheduleType("Recurring"); + await settingsSchedulesPage.selectScheduleFrequency("Weekly"); + await settingsSchedulesPage.validateSaveDisabled(); + await settingsSchedulesPage.selectWeekday("Monday"); + await settingsSchedulesPage.validateSaveEnabled(); + }); + + await test.step("Validate monthly day-of-month input", async () => { + await settingsSchedulesPage.selectScheduleFrequency("Monthly"); + await settingsSchedulesPage.inputDayOfMonth("0"); + await settingsSchedulesPage.validateValidationMessage("Enter a day between 1 and 31"); + await settingsSchedulesPage.validateSaveDisabled(); + await settingsSchedulesPage.inputDayOfMonth("15"); + await settingsSchedulesPage.validateSaveEnabled(); + }); + + await test.step("Save the recurring schedule", async () => { + await settingsSchedulesPage.clickSaveSchedule(); + }); + + await test.step("Validate the recurring schedule summary", async () => { + await settingsSchedulesPage.validateScheduleVisible(scheduleName); + await settingsSchedulesPage.validateScheduleStatus(scheduleName, "Active"); + await settingsSchedulesPage.validateScheduleSummary(scheduleName, "15th day of month"); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/securitySettings.spec.ts b/client/e2eTests/protoFleet/spec/securitySettings.spec.ts new file mode 100644 index 000000000..a4073a9c8 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/securitySettings.spec.ts @@ -0,0 +1,147 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { generateRandomText, generateRandomUsername } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { SettingsPage } from "../pages/settings"; +import { SettingsSecurityPage } from "../pages/settingsSecurity"; + +test.describe("Proto Fleet - Security Settings", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Ensure default admin credentials", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + try { + const page = await context.newPage(); + const authPage = new AuthPage(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const settingsSecurityPage = new SettingsSecurityPage(page, isMobile); + + const tryLogin = async (candidateUsername: string, candidatePassword: string) => { + await page.goto("/auth"); + await authPage.inputUsername(candidateUsername); + await authPage.inputPassword(candidatePassword); + await authPage.clickLogin(); + + try { + await authPage.validateLoggedIn(3000); + return true; + } catch { + return false; + } + }; + + let loggedIn = await tryLogin(username, password); + + if (!loggedIn) { + // Default credentials failed + loggedIn = await tryLogin(newUsername, password); + + if (loggedIn) { + // Only username needs to be reverted + await settingsPage.navigateToSecuritySettings(); + await settingsSecurityPage.clickUpdateUsername(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewUsername(username); + await settingsSecurityPage.clickConfirmUsername(); + await settingsSecurityPage.validateUsernameChangeToast(); + } else { + // Both username and password need to be reverted + loggedIn = await tryLogin(newUsername, newPassword); + if (!loggedIn) { + throw new Error("Unable to log in with updated admin credentials during cleanup."); + } + + await settingsPage.navigateToSecuritySettings(); + await settingsSecurityPage.clickUpdatePassword(); + await settingsSecurityPage.inputCurrentPassword(newPassword); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewPassword(password); + await settingsSecurityPage.inputConfirmPassword(password); + await settingsSecurityPage.clickConfirmPassword(); + await settingsSecurityPage.validatePasswordChangeToast(); + + await settingsSecurityPage.clickUpdateUsername(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewUsername(username); + await settingsSecurityPage.clickConfirmUsername(); + await settingsSecurityPage.validateUsernameChangeToast(); + } + } + } finally { + await context.close(); + } + }); + + const username = testConfig.users.admin.username; + const password = testConfig.users.admin.password; + + const newUsername = generateRandomUsername(); + const newPassword = generateRandomText("A1!"); + + test("Update admin username and password", async ({ authPage, settingsPage, settingsSecurityPage }) => { + await test.step("Log in as admin", async () => { + await authPage.inputUsername(username); + await authPage.inputPassword(password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Change admin username", async () => { + await settingsSecurityPage.clickUpdateUsername(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewUsername(newUsername); + await settingsSecurityPage.clickConfirmUsername(); + await settingsSecurityPage.validateUsernameChangeToast(); + await settingsSecurityPage.validateUsername(newUsername); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Log in with new username", async () => { + await authPage.inputUsername(newUsername); + await authPage.inputPassword(password); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + + await test.step("Navigate to Security Settings", async () => { + await settingsPage.navigateToSecuritySettings(); + }); + + await test.step("Change admin password", async () => { + await settingsSecurityPage.clickUpdatePassword(); + await settingsSecurityPage.inputCurrentPassword(password); + await settingsSecurityPage.clickConfirm(); + await settingsSecurityPage.inputNewPassword(newPassword); + await settingsSecurityPage.inputConfirmPassword(newPassword); + await settingsSecurityPage.clickConfirmPassword(); + await settingsSecurityPage.validatePasswordChangeToast(); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Log in with new password", async () => { + await authPage.inputUsername(newUsername); + await authPage.inputPassword(newPassword); + await authPage.clickLogin(); + await authPage.validateLoggedIn(); + }); + }); +}); diff --git a/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts b/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts new file mode 100644 index 000000000..044c36a72 --- /dev/null +++ b/client/e2eTests/protoFleet/spec/teamAccounts.spec.ts @@ -0,0 +1,245 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { CommonSteps } from "../helpers/commonSteps"; +import { generateRandomUsername } from "../helpers/testDataHelper"; +import { AuthPage } from "../pages/auth"; +import { MinersPage } from "../pages/miners"; +import { SettingsPage } from "../pages/settings"; +import { SettingsTeamPage } from "../pages/settingsTeam"; + +test.describe("Proto Fleet - Team Accounts", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test.afterAll("CLEANUP: Deactivate any team members created during tests", async ({ browser }, testInfo) => { + const isMobile = testInfo.project.use?.isMobile ?? false; + const context = await browser.newContext({ baseURL: testConfig.baseUrl }); + + try { + const page = await context.newPage(); + await page.goto("/"); + + const authPage = new AuthPage(page, isMobile); + const minersPage = new MinersPage(page, isMobile); + const settingsPage = new SettingsPage(page, isMobile); + const settingsTeamPage = new SettingsTeamPage(page, isMobile); + const commonSteps = new CommonSteps(authPage, minersPage); + + await commonSteps.loginAsAdmin(); + + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + await settingsTeamPage.validateMemberVisible("admin"); + + const teamMemberRows = await page.getByTestId("list-row").all(); + const usernamesToDeactivate: string[] = []; + + for (const row of teamMemberRows) { + const usernameElement = row.locator(`//td[@data-testid='username']//span`); + const username = await usernameElement.textContent(); + + const trimmedUsername = username?.trim(); + if (trimmedUsername && trimmedUsername.startsWith("username_")) { + usernamesToDeactivate.push(trimmedUsername); + } + } + + for (const username of usernamesToDeactivate) { + await settingsTeamPage.clickMemberActionsMenu(username); + await settingsTeamPage.clickDeactivate(); + await settingsTeamPage.clickConfirmDeactivation(); + await settingsTeamPage.validateMemberNotInList(username); + } + } finally { + await context.close(); + } + }); + + test("Add team member", async ({ settingsPage, settingsTeamPage, commonSteps }) => { + await test.step("Log in as admin", async () => { + await commonSteps.loginAsAdmin(); + }); + + await test.step("Navigate to Team Settings", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + const username = generateRandomUsername(); + + await test.step("Add a new team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + }); + + await test.step("Validate member was added", async () => { + await settingsTeamPage.validateMemberAdded(); + await settingsTeamPage.validateCopyPasswordButtonVisible(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Validate member appears in list with correct role and login status", async () => { + await settingsTeamPage.validateMemberRole(username, "Admin"); + await settingsTeamPage.validateMemberLastLogin(username, "Never"); + }); + }); + + test("New member log in", async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { + let username = generateRandomUsername(); + let tempPassword: string; + + await test.step("Log in as admin and navigate to team settings", async () => { + await commonSteps.loginAsAdmin(); + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + await test.step("Add a new team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + await settingsTeamPage.validateMemberAdded(); + tempPassword = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + await settingsTeamPage.validateMemberVisible(username); + }); + + await test.step("Log out as admin", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Log in as new member with temporary password", async () => { + await authPage.inputUsername(username); + await authPage.inputPassword(tempPassword); + await authPage.clickLogin(); + }); + + await test.step("Set new password", async () => { + await authPage.inputNewPassword("Password123!"); + await authPage.inputConfirmPassword("Password123!"); + await authPage.clickContinue(); + await authPage.clickLoginButton(); + await authPage.validateLoggedIn(); + }); + + await test.step("Verify no admin rights", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + await settingsTeamPage.validateNoAdminRights(); + }); + }); + + test("New member password reset", async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { + let username = generateRandomUsername(); + let tempPassword1: string; + let tempPassword2: string; + + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to team settings", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + await test.step("Add team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + await settingsTeamPage.validateMemberAdded(); + tempPassword1 = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Reset member password", async () => { + await settingsTeamPage.clickMemberActionsMenu(username); + await settingsTeamPage.clickResetPassword(); + await settingsTeamPage.clickResetMemberPasswordConfirm(); + await settingsTeamPage.validatePasswordReset(); + tempPassword2 = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Log out as admin", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Attempt login with initial (wrong) temp password", async () => { + await authPage.inputUsername(username); + await authPage.clickPasswordVisibilityToggle(); + await authPage.inputPassword(tempPassword1); + await authPage.clickLogin(); + await authPage.validateInvalidCredentials(); + }); + + await test.step("Log in with new temp password", async () => { + await authPage.inputUsername(username); + await authPage.inputPassword(tempPassword2); + await authPage.clickLogin(); + await authPage.validateUpdatePasswordTitle(); + }); + + await test.step("Set new password", async () => { + await authPage.inputNewPassword("Password123!"); + await authPage.inputConfirmPassword("Password123!"); + await authPage.clickContinue(); + await authPage.validatePasswordSaved(); + }); + + await test.step("Complete login", async () => { + await authPage.clickLoginButton(); + await authPage.validateLoggedIn(); + }); + + await test.step("Log out", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + }); + + test("Deactivate team member", async ({ authPage, settingsPage, settingsTeamPage, commonSteps }) => { + let username = generateRandomUsername(); + let tempPassword: string; + + await commonSteps.loginAsAdmin(); + + await test.step("Navigate to team settings", async () => { + await settingsPage.navigateToTeamSettings(); + await settingsTeamPage.validateTeamSettingsPageOpened(); + }); + + await test.step("Add team member", async () => { + await settingsTeamPage.clickAddTeamMember(); + await settingsTeamPage.inputMemberUsername(username); + await settingsTeamPage.clickSaveTeamMember(); + await settingsTeamPage.validateMemberAdded(); + tempPassword = await settingsTeamPage.getTemporaryPassword(); + await settingsTeamPage.clickDone(); + }); + + await test.step("Deactivate the newly added team member", async () => { + await settingsTeamPage.clickMemberActionsMenu(username); + await settingsTeamPage.clickDeactivate(); + await settingsTeamPage.clickConfirmDeactivation(); + await settingsTeamPage.validateMemberDeactivatedMessage(username); + await settingsTeamPage.validateMemberNotInList(username); + }); + + await test.step("Log out as admin", async () => { + await authPage.logout(); + await authPage.validateRedirectedToAuth(); + }); + + await test.step("Attempt login with temp password", async () => { + await authPage.inputUsername(username); + await authPage.clickPasswordVisibilityToggle(); + await authPage.inputPassword(tempPassword); + await authPage.clickLogin(); + await authPage.validateInvalidCredentials(); + }); + }); +}); diff --git a/client/e2eTests/protoOS/.gitignore b/client/e2eTests/protoOS/.gitignore new file mode 100644 index 000000000..3bae91a8f --- /dev/null +++ b/client/e2eTests/protoOS/.gitignore @@ -0,0 +1,28 @@ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ + +# Environment variables +.env +.env.local +.env*.local + +# Local test config (not committed) +config/test.config.local.ts + +# macOS +.DS_Store + +# Editor +.vscode/ +.idea/ + +# Lock files (optional - remove if you want to commit them) +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/client/e2eTests/protoOS/README.md b/client/e2eTests/protoOS/README.md new file mode 100644 index 000000000..464090415 --- /dev/null +++ b/client/e2eTests/protoOS/README.md @@ -0,0 +1 @@ +# TODO diff --git a/client/e2eTests/protoOS/config/test.config.defaults.ts b/client/e2eTests/protoOS/config/test.config.defaults.ts new file mode 100644 index 000000000..f8dc5cc07 --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.defaults.ts @@ -0,0 +1,18 @@ +export const defaultTestConfig = { + baseUrl: "http://localhost:3000", + + admin: { + username: "admin", + password: "Pass123!", + }, + + pool: { + url: "stratum+tcp://mine.ocean.xyz:3334", + }, + + testTimeout: 180000, + actionTimeout: 30000, + interval: 500, +}; + +export type TestConfig = typeof defaultTestConfig; diff --git a/client/e2eTests/protoOS/config/test.config.local.d.ts b/client/e2eTests/protoOS/config/test.config.local.d.ts new file mode 100644 index 000000000..d31079dc8 --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.local.d.ts @@ -0,0 +1,7 @@ +import type { TestConfig } from "./test.config.defaults"; + +// Declare optional local config module +// This file may not exist (it's gitignored for local development) +declare module "./test.config.local" { + export const localTestConfig: Partial | undefined; +} diff --git a/client/e2eTests/protoOS/config/test.config.local.example.ts b/client/e2eTests/protoOS/config/test.config.local.example.ts new file mode 100644 index 000000000..70295ae5a --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.local.example.ts @@ -0,0 +1,28 @@ +import type { TestConfig } from "./test.config.defaults"; + +/** + * Local test configuration overrides. + * + * HOW TO USE: + * 1. Copy this file as test.config.local.ts + * 2. Customize values for your local environment + * 3. The .local.ts file is gitignored and won't be committed + * + * You can override any property from the default config. + * Your IDE will provide autocomplete for all available options. + */ +export const localTestConfig: Partial = { + // Uncomment and modify values as needed: + // testTimeout: 60000, + // actionTimeout: 15000, + // interval: 500, + // admin: { + // password: "your-local-admin-password", + // }, + // pool: { + // name: "Your Pool Name", + // url: "stratum+tcp://your-pool.com:3333", + // username: "your-username", + // password: "your-password", + // }, +}; diff --git a/client/e2eTests/protoOS/config/test.config.ts b/client/e2eTests/protoOS/config/test.config.ts new file mode 100644 index 000000000..65748b58e --- /dev/null +++ b/client/e2eTests/protoOS/config/test.config.ts @@ -0,0 +1,24 @@ +import { defaultTestConfig, type TestConfig } from "./test.config.defaults"; + +let localConfig: Partial = {}; +try { + // Try to import local config if it exists (file is gitignored) + // To create: copy test.config.local.example.ts to test.config.local.ts + const module = await import("./test.config.local"); + localConfig = module.localTestConfig || {}; +} catch { + // Local config doesn't exist, use defaults only +} + +// Merge default config with local overrides +export const testConfig: TestConfig = { + ...defaultTestConfig, + ...localConfig, + admin: { + ...defaultTestConfig.admin, + ...localConfig.admin, + }, +}; + +export const DEFAULT_TIMEOUT = testConfig.actionTimeout; +export const DEFAULT_INTERVAL = testConfig.interval; diff --git a/client/e2eTests/protoOS/fixtures/pageFixtures.ts b/client/e2eTests/protoOS/fixtures/pageFixtures.ts new file mode 100644 index 000000000..d6fbf1b30 --- /dev/null +++ b/client/e2eTests/protoOS/fixtures/pageFixtures.ts @@ -0,0 +1,92 @@ +// NOTE: eslint incorrectly identifies 'use' as react hook +/* eslint-disable react-hooks/rules-of-hooks */ +import { test as base } from "@playwright/test"; +import { CommonSteps } from "../helpers/commonSteps"; +import { AuthenticationPage } from "../pages/authentication"; +import { HeaderComponent } from "../pages/components/header"; +import { NavigationComponent } from "../pages/components/navigation"; +import { SleepWakeDialogsComponent } from "../pages/components/sleepWakeDialog"; +import { WakeCalloutComponent } from "../pages/components/wakeCallout"; +import { CoolingPage } from "../pages/cooling"; +import { DiagnosticsPage } from "../pages/diagnostics"; +import { GeneralPage } from "../pages/general"; +import { HardwarePage } from "../pages/hardware"; +import { HomePage } from "../pages/home"; +import { LogsPage } from "../pages/logs"; +import { WelcomePage } from "../pages/onboarding"; +import { PoolsPage } from "../pages/pools"; + +type PageFixtures = { + welcomePage: WelcomePage; + homePage: HomePage; + poolsPage: PoolsPage; + diagnosticsPage: DiagnosticsPage; + logsPage: LogsPage; + authenticationPage: AuthenticationPage; + generalPage: GeneralPage; + hardwarePage: HardwarePage; + coolingPage: CoolingPage; + commonSteps: CommonSteps; + navigationComponent: NavigationComponent; + headerComponent: HeaderComponent; + sleepWakeDialogsComponent: SleepWakeDialogsComponent; + wakeCalloutComponent: WakeCalloutComponent; +}; + +export const test = base.extend({ + welcomePage: async ({ page, isMobile }, use) => { + await use(new WelcomePage(page, isMobile)); + }, + homePage: async ({ page, isMobile }, use) => { + await use(new HomePage(page, isMobile)); + }, + poolsPage: async ({ page, isMobile }, use) => { + await use(new PoolsPage(page, isMobile)); + }, + diagnosticsPage: async ({ page, isMobile }, use) => { + await use(new DiagnosticsPage(page, isMobile)); + }, + logsPage: async ({ page, isMobile }, use) => { + await use(new LogsPage(page, isMobile)); + }, + authenticationPage: async ({ page, isMobile }, use) => { + await use(new AuthenticationPage(page, isMobile)); + }, + generalPage: async ({ page, isMobile }, use) => { + await use(new GeneralPage(page, isMobile)); + }, + hardwarePage: async ({ page, isMobile }, use) => { + await use(new HardwarePage(page, isMobile)); + }, + coolingPage: async ({ page, isMobile }, use) => { + await use(new CoolingPage(page, isMobile)); + }, + navigationComponent: async ({ page, isMobile }, use) => { + await use(new NavigationComponent(page, isMobile)); + }, + headerComponent: async ({ page, isMobile }, use) => { + await use(new HeaderComponent(page, isMobile)); + }, + sleepWakeDialogsComponent: async ({ page, isMobile }, use) => { + await use(new SleepWakeDialogsComponent(page, isMobile)); + }, + wakeCalloutComponent: async ({ page, isMobile }, use) => { + await use(new WakeCalloutComponent(page, isMobile)); + }, + commonSteps: async ( + { welcomePage, navigationComponent, headerComponent, sleepWakeDialogsComponent, wakeCalloutComponent }, + use, + ) => { + await use( + new CommonSteps( + welcomePage, + navigationComponent, + headerComponent, + sleepWakeDialogsComponent, + wakeCalloutComponent, + ), + ); + }, +}); + +export const expect = test.expect; diff --git a/client/e2eTests/protoOS/helpers/commonSteps.ts b/client/e2eTests/protoOS/helpers/commonSteps.ts new file mode 100644 index 000000000..bfec8aa69 --- /dev/null +++ b/client/e2eTests/protoOS/helpers/commonSteps.ts @@ -0,0 +1,98 @@ +import { test } from "@playwright/test"; +import { testConfig } from "../config/test.config"; +import { HeaderComponent } from "../pages/components/header"; +import { NavigationComponent } from "../pages/components/navigation"; +import { SleepWakeDialogsComponent } from "../pages/components/sleepWakeDialog"; +import { WakeCalloutComponent } from "../pages/components/wakeCallout"; +import { WelcomePage } from "../pages/onboarding"; + +export class CommonSteps { + constructor( + private welcomePage: WelcomePage, + private navigationComponent: NavigationComponent, + private headerComponent: HeaderComponent, + private sleepWakeDialogsComponent: SleepWakeDialogsComponent, + private wakeCalloutComponent: WakeCalloutComponent, + ) {} + + async authenticateAsAdmin() { + await test.step("Authenticate as admin", async () => { + await this.welcomePage.inputLoginPassword(testConfig.admin.password); + await this.welcomePage.clickLoginButton(); + await this.welcomePage.validateToastMessage("You are now logged in as admin"); + }); + } + + async navigateToHome() { + await test.step("Navigate to Home", async () => { + await this.navigationComponent.navigateToHome(); + }); + } + + async navigateToDiagnostics() { + await test.step("Navigate to Diagnostics", async () => { + await this.navigationComponent.navigateToDiagnostics(); + }); + } + + async navigateToLogs() { + await test.step("Navigate to Logs", async () => { + await this.navigationComponent.navigateToLogs(); + }); + } + + async navigateToAuthenticationSettings(expand: boolean = true) { + await test.step("Navigate to Authentication settings", async () => { + await this.navigationComponent.navigateToAuthenticationSettings(expand); + }); + } + + async navigateToGeneralSettings(expand: boolean = true) { + await test.step("Navigate to General settings", async () => { + await this.navigationComponent.navigateToGeneralSettings(expand); + }); + } + + async navigateToPoolsSettings(expand: boolean = true) { + await test.step("Navigate to Pools settings", async () => { + await this.navigationComponent.navigateToPoolsSettings(expand); + }); + } + + async navigateToHardwareSettings(expand: boolean = true) { + await test.step("Navigate to Hardware settings", async () => { + await this.navigationComponent.navigateToHardwareSettings(expand); + }); + } + + async navigateToCoolingSettings(expand: boolean = true) { + await test.step("Navigate to Cooling settings", async () => { + await this.navigationComponent.navigateToCoolingSettings(expand); + }); + } + + async validateWakeCallout() { + await test.step(`Validate miner asleep status in current page`, async () => { + await this.wakeCalloutComponent.validateWakeCallout(); + }); + } + + async putMinerToSleep() { + await test.step(`Put miner to sleep from current page`, async () => { + await this.headerComponent.clickPowerButton(); + await this.headerComponent.clickPowerPopoverButton("Sleep"); + await this.sleepWakeDialogsComponent.clickEnterSleepMode(); + await this.sleepWakeDialogsComponent.validateEnteringSleepDialog(); + }); + } + + async wakeMinerFromCallout() { + await test.step(`Wake miner up from current page callout`, async () => { + await this.wakeCalloutComponent.clickWakeMinerInCallout(); + await this.sleepWakeDialogsComponent.clickWakeMinerInDialog(); + await this.sleepWakeDialogsComponent.validateWakingDialog(); + await this.headerComponent.validateMinerStatus("Hashing"); + await this.wakeCalloutComponent.validateWakeCalloutNotVisible(); + }); + } +} diff --git a/client/e2eTests/protoOS/helpers/testDataHelper.ts b/client/e2eTests/protoOS/helpers/testDataHelper.ts new file mode 100644 index 000000000..189744556 --- /dev/null +++ b/client/e2eTests/protoOS/helpers/testDataHelper.ts @@ -0,0 +1,8 @@ +export function generateRandomText(prefix: string): string { + const randomCode = Math.random().toString(36).substring(2, 9); + return `${prefix}_${randomCode}`; +} + +export function generateRandomUsername(): string { + return generateRandomText("username"); +} diff --git a/client/e2eTests/protoOS/pages/authentication.ts b/client/e2eTests/protoOS/pages/authentication.ts new file mode 100644 index 000000000..a03438bbe --- /dev/null +++ b/client/e2eTests/protoOS/pages/authentication.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class AuthenticationPage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/base.ts b/client/e2eTests/protoOS/pages/base.ts new file mode 100644 index 000000000..d944717e8 --- /dev/null +++ b/client/e2eTests/protoOS/pages/base.ts @@ -0,0 +1,77 @@ +import { expect, Page } from "@playwright/test"; + +export class BasePage { + constructor( + protected page: Page, + protected isMobile: boolean = false, + ) {} + + async reloadPage() { + await this.page.reload(); + } + + async validateLoggedIn() { + await expect(this.page.getByTestId("power-button")).toBeVisible(); + } + + async validateTitle(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()="${expectedTitle}"]`); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleInModal(expectedTitle: string) { + const titleLocator = this.page.locator( + `//*[@data-testid='modal']//*[contains(@class,'heading')][text()="${expectedTitle}"]`, + ); + await expect(titleLocator).toBeVisible(); + } + + async validateTitleNotVisible(expectedTitle: string) { + const titleLocator = this.page.locator(`//*[contains(@class,'heading')][text()="${expectedTitle}"]`); + await expect(titleLocator).toBeHidden(); + } + + async validateTextIsVisible(text: string) { + await expect(this.page.getByText(text)).toBeVisible(); + } + + async validateTextInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeVisible(); + } + + async validateTextNotInModal(text: string) { + await expect(this.page.getByTestId("modal").getByText(text)).toBeHidden(); + } + + async validateToastMessage(message: string) { + await expect(this.page.getByTestId("toast").getByText(message)).toBeVisible(); + } + + async inputLoginPassword(password: string) { + await this.page.getByTestId("password").fill(password); + } + + async clickLoginButton() { + await this.page.getByTestId("login-button").click(); + } + + async clickButton(text: string) { + await this.page.getByRole("button", { name: text, disabled: false }).click(); + } + + async clickIn(text: string, testId: string) { + await this.page.getByTestId(testId).getByRole("button", { name: text, disabled: false }).click(); + } + + async validateModalIsOpen() { + await expect(this.page.getByTestId("modal")).toBeVisible(); + } + + async validateModalIsClosed() { + await expect(this.page.getByTestId("modal")).toBeHidden(); + } + + async validateButtonIsVisible(text: string) { + await expect(this.page.getByRole("button", { name: text })).toBeVisible(); + } +} diff --git a/client/e2eTests/protoOS/pages/components/header.ts b/client/e2eTests/protoOS/pages/components/header.ts new file mode 100644 index 000000000..ef9f34479 --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/header.ts @@ -0,0 +1,23 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class HeaderComponent extends BasePage { + async clickPowerButton() { + await this.page.getByTestId("power-button").click(); + } + + async clickPowerPopoverButton(buttonText: string) { + const popover = this.page.getByTestId("power-popover"); + await popover.getByRole("button", { name: buttonText }).click(); + } + + async clickMinerStatusButton(status: string = "Sleeping") { + const header = this.page.getByTestId("page-header"); + await header.getByRole("button", { name: status }).click(); + } + + async validateMinerStatus(status: string) { + const header = this.page.getByTestId("page-header"); + await expect(header.getByRole("button", { name: status })).toBeVisible(); + } +} diff --git a/client/e2eTests/protoOS/pages/components/navigation.ts b/client/e2eTests/protoOS/pages/components/navigation.ts new file mode 100644 index 000000000..c6e20dd60 --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/navigation.ts @@ -0,0 +1,75 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class NavigationComponent extends BasePage { + async clickNavigationMenuIfMobile() { + if (this.isMobile) { + await this.page.getByTestId("navigation-menu-button").click(); + } + } + + async clickNavigationItem(itemName: string) { + await this.page.getByTestId("navigation").getByRole("button", { name: itemName }).click(); + } + + async clickNavigationItemInSettings(itemName: string, expand: boolean) { + if (expand) { + await this.clickNavigationItem("Settings"); + } + await this.clickNavigationItem(itemName); + } + + async navigateToHome() { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItem("Home"); + await expect(this.page).toHaveURL(/.*\/hashrate/); + await this.validateTitle("Home"); + } + + async navigateToDiagnostics() { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItem("Diagnostics"); + await expect(this.page).toHaveURL(/.*\/diagnostics/); + await this.validateTitle("Diagnostics"); + } + + async navigateToLogs() { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItem("Logs"); + await expect(this.page).toHaveURL(/.*\/logs/); + } + + async navigateToAuthenticationSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Authentication", expand); + await expect(this.page).toHaveURL(/.*\/settings\/authentication/); + await this.validateTitle("Update your admin login"); + } + + async navigateToGeneralSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("General", expand); + await expect(this.page).toHaveURL(/.*\/settings\/general/); + await this.validateTitle("General"); + } + + async navigateToPoolsSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Pools", expand); + await expect(this.page).toHaveURL(/.*\/settings\/mining-pools/); + } + + async navigateToHardwareSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Hardware", expand); + await expect(this.page).toHaveURL(/.*\/settings\/hardware/); + await this.validateTitle("Hardware"); + } + + async navigateToCoolingSettings(expand: boolean) { + await this.clickNavigationMenuIfMobile(); + await this.clickNavigationItemInSettings("Cooling", expand); + await expect(this.page).toHaveURL(/.*\/settings\/cooling/); + await this.validateTitle("Cooling"); + } +} diff --git a/client/e2eTests/protoOS/pages/components/sleepWakeDialog.ts b/client/e2eTests/protoOS/pages/components/sleepWakeDialog.ts new file mode 100644 index 000000000..937bce72b --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/sleepWakeDialog.ts @@ -0,0 +1,39 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class SleepWakeDialogsComponent extends BasePage { + async clickEnterSleepMode() { + const dialog = this.page.getByTestId("warn-sleep-dialog"); + await dialog.getByRole("button", { name: "Enter sleep mode" }).click(); + } + + async validateEnteringSleepDialog() { + const dialog = this.page.getByTestId("entering-sleep-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Entering sleep mode"); + await expect(dialog).toBeHidden(); + } + + async clickWakeMinerInDialog() { + const dialog = this.page.getByTestId("warn-wake-up-dialog"); + await dialog.getByRole("button", { name: "Wake up miner" }).click(); + } + + async validateWakingDialog() { + const dialog = this.page.getByTestId("waking-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Waking up miner"); + await expect(dialog).toBeHidden(); + } + + async validateMinerAsleepModal() { + await this.validateModalIsOpen(); + await this.validateTitleInModal("Miner is asleep"); + await this.validateTextInModal("Done"); + } + + async clickWakeMinerInModal() { + const modal = this.page.getByTestId("modal"); + await modal.getByRole("button", { name: "Wake miner" }).click(); + } +} diff --git a/client/e2eTests/protoOS/pages/components/wakeCallout.ts b/client/e2eTests/protoOS/pages/components/wakeCallout.ts new file mode 100644 index 000000000..cbebd5a6a --- /dev/null +++ b/client/e2eTests/protoOS/pages/components/wakeCallout.ts @@ -0,0 +1,20 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "../base"; + +export class WakeCalloutComponent extends BasePage { + async validateWakeCallout() { + const callout = this.page.getByTestId("callout"); + await expect(callout.getByText("This miner is asleep and is not hashing.")).toBeVisible(); + await expect(callout.getByRole("button", { name: "Wake up miner" })).toBeVisible(); + } + + async validateWakeCalloutNotVisible() { + const callout = this.page.getByTestId("callout"); + await expect(callout.getByRole("button", { name: "Wake up miner" })).toBeHidden(); + } + + async clickWakeMinerInCallout() { + const callout = this.page.getByTestId("callout"); + await callout.getByRole("button", { name: "Wake up miner" }).click(); + } +} diff --git a/client/e2eTests/protoOS/pages/cooling.ts b/client/e2eTests/protoOS/pages/cooling.ts new file mode 100644 index 000000000..1f78186ff --- /dev/null +++ b/client/e2eTests/protoOS/pages/cooling.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class CoolingPage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/diagnostics.ts b/client/e2eTests/protoOS/pages/diagnostics.ts new file mode 100644 index 000000000..dde6ff8a1 --- /dev/null +++ b/client/e2eTests/protoOS/pages/diagnostics.ts @@ -0,0 +1,135 @@ +import { expect } from "@playwright/test"; +import { DEFAULT_INTERVAL, DEFAULT_TIMEOUT } from "../config/test.config"; +import { BasePage } from "./base"; + +export class DiagnosticsPage extends BasePage { + async clickFilterButton(filterName: string) { + await this.page.getByTestId("segmented-control").getByRole("button", { name: filterName }).click(); + } + + private section(sectionTestIdName: string) { + return this.page.getByTestId(`component-section-${sectionTestIdName}`); + } + + async validateAllSectionsVisible(sectionTestIdNames: string[]) { + for (const sectionTestIdName of sectionTestIdNames) { + await expect(this.section(sectionTestIdName)).toBeVisible(); + } + } + + async validateOnlySectionVisible(selectedSectionTestIdName: string, allSectionTestIdNames: string[]) { + await expect(this.section(selectedSectionTestIdName)).toBeVisible(); + + for (const sectionTestIdName of allSectionTestIdNames) { + if (sectionTestIdName === selectedSectionTestIdName) continue; + await expect(this.section(sectionTestIdName)).toBeHidden(); + } + } + + async validateCardCountInSection(sectionTestIdName: string, expectedCardCount: number) { + await expect(this.section(sectionTestIdName).getByTestId("card")).toHaveCount(expectedCardCount); + } + + private cardInSection(sectionTestIdName: string, index: number) { + return this.section(sectionTestIdName).getByTestId("card").nth(index); + } + + async cardHasMoreInfoButton(sectionTestIdName: string, cardIndex: number): Promise { + const card = this.cardInSection(sectionTestIdName, cardIndex); + return (await card.getByRole("button", { name: "More info" }).count()) > 0; + } + + async validateEmptySlotCard(sectionTestIdName: string, cardIndex: number, expectedText: string | RegExp) { + const card = this.cardInSection(sectionTestIdName, cardIndex); + await expect(card.getByText(expectedText)).toBeVisible(); + await expect(card.getByRole("button", { name: "More info" })).toHaveCount(0); + } + + async validateCardInfoOrEmptySlot( + sectionTestIdName: string, + cardIndex: number, + expected: { + metrics: Array<{ label: string | RegExp; valuePattern: RegExp }>; + metadata: Array<{ label: string }>; + emptySlotText: RegExp; + allowExtraMetadataRows?: boolean; + }, + ): Promise<"info" | "empty"> { + const hasMoreInfo = await this.cardHasMoreInfoButton(sectionTestIdName, cardIndex); + + if (!hasMoreInfo) { + await this.validateEmptySlotCard(sectionTestIdName, cardIndex, expected.emptySlotText); + return "empty"; + } + + await this.openMoreInfoForCard(sectionTestIdName, cardIndex); + await this.validateStatusModalMetrics(expected.metrics); + await this.validateStatusModalMetadataRows(expected.metadata, { allowExtraRows: expected.allowExtraMetadataRows }); + await this.closeStatusModal(); + return "info"; + } + + async openMoreInfoForCard(sectionTestIdName: string, cardIndex: number) { + const card = this.cardInSection(sectionTestIdName, cardIndex); + await card.getByRole("button", { name: "More info" }).click(); + await this.validateModalIsOpen(); + } + + async closeStatusModal() { + await this.clickIn("Done", "modal"); + await this.validateModalIsClosed(); + } + + async validateStatusModalMetrics(expected: Array<{ label: string | RegExp; valuePattern: RegExp }>) { + const metrics = this.page.getByTestId("status-modal-metric"); + await expect(metrics).toHaveCount(expected.length); + + for (const { label, valuePattern } of expected) { + const labelLocator = + typeof label === "string" + ? this.page.getByTestId("status-modal-metric-label").getByText(label, { exact: true }) + : this.page.getByTestId("status-modal-metric-label").getByText(label); + + const metric = metrics.filter({ + has: labelLocator, + }); + await expect(metric).toHaveCount(1); + + await expect(metric.first().getByTestId("status-modal-metric-label")).toHaveText(label); + await expect(metric.first().getByTestId("status-modal-metric-value")).toHaveText(valuePattern); + } + } + + async validateStatusModalMetadataRows(expected: Array<{ label: string }>, options?: { allowExtraRows?: boolean }) { + const rows = this.page.getByTestId("status-modal-metadata-row"); + + if (options?.allowExtraRows) { + await expect(async () => { + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(expected.length); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } else { + await expect(rows).toHaveCount(expected.length); + } + + for (const { label } of expected) { + const row = rows.filter({ + has: this.page.getByTestId("status-modal-metadata-label").getByText(label, { exact: true }), + }); + await expect(row).toHaveCount(1); + + await expect(row.first().getByTestId("status-modal-metadata-label")).toHaveText(label); + await expect(row.first().getByTestId("status-modal-metadata-value")).toHaveText(/\S+/); + } + } + + async validateTemperaturesInFormat(expectedCount: number, temperaturePattern: RegExp, oppositePattern: RegExp) { + const page = this.page; + const textFields = page.locator("div[class*='text-primary']"); + + await expect(async () => { + await expect(textFields.filter({ hasText: temperaturePattern })).toHaveCount(expectedCount); + await expect(textFields.filter({ hasText: oppositePattern })).toHaveCount(0); + }).toPass({ timeout: DEFAULT_TIMEOUT, intervals: [DEFAULT_INTERVAL] }); + } +} diff --git a/client/e2eTests/protoOS/pages/general.ts b/client/e2eTests/protoOS/pages/general.ts new file mode 100644 index 000000000..7c1edac86 --- /dev/null +++ b/client/e2eTests/protoOS/pages/general.ts @@ -0,0 +1,32 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class GeneralPage extends BasePage { + async clickTemperatureButton() { + await this.page.locator('[data-testid="temperature-button"]').click(); + } + + async selectFahrenheit() { + await this.page.locator('//*[@data-testid="fahrenheit-option"]//input').click(); + } + + async selectCelsius() { + await this.page.locator('//*[@data-testid="celsius-option"]//input').click(); + } + + async clickDoneButton() { + await this.clickButton("Done"); + } + + private async validateTemperatureFormat(format: string) { + await expect(this.page.locator('[data-testid="temperature-button"]')).toHaveText(format); + } + + async validateTemperatureFormatFahrenheit() { + await this.validateTemperatureFormat("Fahrenheit"); + } + + async validateTemperatureFormatCelsius() { + await this.validateTemperatureFormat("Celsius"); + } +} diff --git a/client/e2eTests/protoOS/pages/hardware.ts b/client/e2eTests/protoOS/pages/hardware.ts new file mode 100644 index 000000000..6c37207d2 --- /dev/null +++ b/client/e2eTests/protoOS/pages/hardware.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class HardwarePage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/home.ts b/client/e2eTests/protoOS/pages/home.ts new file mode 100644 index 000000000..95f84d3b4 --- /dev/null +++ b/client/e2eTests/protoOS/pages/home.ts @@ -0,0 +1,153 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class HomePage extends BasePage { + async clickTab(tabName: string) { + await this.page.getByTestId(`tab-${tabName}`).click(); + } + + async validateTabHeading(tabName: string, expectedHeading: string) { + const tab = this.page.getByTestId(`tab-${tabName}`); + await expect(tab.locator('[class*="heading"]').first()).toHaveText(expectedHeading); + } + + async validateTabValue(tabName: string, valuePattern: RegExp) { + const tab = this.page.getByTestId(`tab-${tabName}`); + const valueLocator = tab.locator('[class*="heading"]').nth(1); + await expect(valueLocator).toHaveText(valuePattern); + } + + async validateStatsCount(expectedCount: number) { + const statsItems = this.page.getByTestId("stats-item"); + await expect(statsItems).toHaveCount(expectedCount); + } + + async validateStatItem(index: number, expectedLabel: string, valuePattern: RegExp) { + const statItem = this.page.getByTestId("stats-item").nth(index); + await expect(statItem.locator('[class*="heading"]').first()).toHaveText(expectedLabel); + const valueLocator = statItem.locator('[class*="heading"]').nth(1); + await expect(valueLocator).toHaveText(valuePattern); + } + + async hoverOverChart() { + const chart = this.page.getByTestId("line-chart"); + await chart.scrollIntoViewIfNeeded(); + if (this.isMobile) { + await chart.click(); + } else { + await chart.hover(); + } + } + + async validateChartTooltipWithHashboards(expectedValuePattern: RegExp) { + const tooltip = this.page.locator(".recharts-tooltip-wrapper"); + await expect(tooltip).toBeVisible(); + await expect(tooltip.getByText("Summary")).toBeVisible(); + await expect(tooltip.locator("div[class*='text-primary']").filter({ hasText: expectedValuePattern })).toHaveCount( + 5, + ); + await expect(tooltip.getByText("Hashboards")).toBeVisible(); + } + + async getFilterButtonBackgroundColors() { + const buttons = this.page.locator('[data-testid^="chart-filter-hashboard-"]'); + await expect(buttons).toHaveCount(4); + + const colors: string[] = []; + for (let i = 0; i < 4; i++) { + const btn = buttons.nth(i); + const colorEl = btn.locator('[style*="background"]'); + const style = await colorEl.getAttribute("style"); + const match = style?.match(/rgba?\(.+\)/); + if (match) { + colors.push(match[0]); + } else { + throw new Error(`No background color found for button ${i + 1}`); + } + } + console.warn("Colors: ", colors); + return colors; + } + + async validateFilterButtonBorder(testId: string, shouldBeActive: boolean) { + const btn = this.page.getByTestId(testId); + const classList = await btn.getAttribute("class"); + if (shouldBeActive) { + expect(classList).toContain("border-core-primary-fill"); + } else { + expect(classList).toContain("border-transparent"); + } + } + + async validateAllFilterButtonBorder(expectedHashboards: string[]) { + await this.validateFilterButtonBorder("chart-filter-summary", expectedHashboards.includes("S")); + const allHashboards = ["1", "2", "3", "4"]; + const allActive = allHashboards.every((h) => expectedHashboards.includes(h)); + await this.validateFilterButtonBorder("chart-filter-all-hashboards", allActive); + for (const h of allHashboards) { + await this.validateFilterButtonBorder(`chart-filter-hashboard-${h}`, expectedHashboards.includes(h)); + } + } + + async validateValueInTooltip(expectedHashboards: string[], backgroundColors: string[]) { + const expectedValuePattern = /(\d+,)?\d+\.\d\sTH\/(S|s)/; + const tooltip = this.page.locator(".recharts-tooltip-wrapper"); + await expect(tooltip).toBeVisible(); + + // Summary + if (expectedHashboards.includes("S")) { + const summary = tooltip.getByText("Summary"); + await expect(summary).toBeVisible(); + const summaryValue = summary.locator("xpath=following-sibling::*[1]"); + await expect(summaryValue).toHaveText(expectedValuePattern); + } else { + await expect(tooltip.getByText("Summary")).toBeHidden(); + } + + // Hashboards + const hashboardNumbers = expectedHashboards.filter((h) => ["1", "2", "3", "4"].includes(h)); + if (hashboardNumbers.length > 0) { + const hashboardsLabel = tooltip.getByText("Hashboards"); + await expect(hashboardsLabel).toBeVisible(); + const hashboardElements = tooltip.locator("//div[text()='Hashboards']/following-sibling::*"); + for (const [i, h] of hashboardNumbers.entries()) { + const hashboard = hashboardElements.nth(i); + await expect(hashboard.locator(`//*[text()='${h}']`)).toBeVisible(); + await expect(hashboard).toHaveText(expectedValuePattern); + const colorIndex = parseInt(h) - 1; + const expectedColor = backgroundColors[colorIndex]; + console.warn(`Checking hashboard ${h} with expected color ${expectedColor}`); + await expect(hashboard.locator(`//*[contains(@style, '${expectedColor}')]`)).toBeVisible(); + } + } else { + await expect(tooltip.getByText("Hashboards")).toBeHidden(); + } + } + + async validateFilteredChart(expectedHashboards: string[]) { + const backgroundColors = await this.getFilterButtonBackgroundColors(); + await this.validateAllFilterButtonBorder(expectedHashboards); + await this.hoverOverChart(); + if (!expectedHashboards.length) { + await this.validateValueInTooltip(["S", "1", "2", "3", "4"], backgroundColors); + } else { + await this.validateValueInTooltip(expectedHashboards, backgroundColors); + } + } + + async validateTemperatureInFormat(temperaturePattern: RegExp) { + await this.validateTabValue("temperature", temperaturePattern); + } + + async validateWarnSleepDialog() { + const dialog = this.page.getByTestId("warn-sleep-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Enter sleep mode?"); + } + + async validateWarnWakeUpDialog() { + const dialog = this.page.getByTestId("warn-wake-up-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("Wake up miner?"); + } +} diff --git a/client/e2eTests/protoOS/pages/logs.ts b/client/e2eTests/protoOS/pages/logs.ts new file mode 100644 index 000000000..5aea10c5e --- /dev/null +++ b/client/e2eTests/protoOS/pages/logs.ts @@ -0,0 +1,3 @@ +import { BasePage } from "./base"; + +export class LogsPage extends BasePage {} diff --git a/client/e2eTests/protoOS/pages/onboarding.ts b/client/e2eTests/protoOS/pages/onboarding.ts new file mode 100644 index 000000000..f80f783ee --- /dev/null +++ b/client/e2eTests/protoOS/pages/onboarding.ts @@ -0,0 +1,58 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class WelcomePage extends BasePage { + async validateWelcomeUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/welcome/); + } + + async validateAuthenticationUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/authentication/); + } + + async validateVerifyUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/verify/); + } + + async inputPassword(password: string) { + await this.page.locator('input[id="password"]').fill(password); + } + + async inputConfirmPassword(password: string) { + await this.page.locator('input[id="confirmPassword"]').fill(password); + } + + async clickContinue() { + await this.clickButton("Continue"); + } + + async validateUsernameFieldDisabledWithValue(expectedValue: string) { + const usernameField = this.page.locator('input[id="username"]'); + await expect(usernameField).toBeDisabled(); + await expect(usernameField).toHaveValue(expectedValue); + } + + async clickGetStartedButton() { + await this.clickButton("Get Started"); + } + + async clickContinueSetup() { + await this.clickButton("Continue setup"); + } + + async validateMiningPoolUrl() { + await expect(this.page).toHaveURL(/.*\/onboarding\/mining-pool/); + } + + async validateDefaultPoolWarningVisible() { + await expect(this.page.getByTestId("warn-default-pool-callout")).toBeVisible(); + } + + async validateDefaultPoolWarningText() { + await this.validateTextIsVisible("A default pool is required to set up your miner."); + } + + async closeDefaultPoolWarning() { + await this.page.getByTestId("warn-default-pool-callout").getByRole("button").click(); + } +} diff --git a/client/e2eTests/protoOS/pages/pools.ts b/client/e2eTests/protoOS/pages/pools.ts new file mode 100644 index 000000000..7c02dda7c --- /dev/null +++ b/client/e2eTests/protoOS/pages/pools.ts @@ -0,0 +1,117 @@ +import { expect } from "@playwright/test"; +import { BasePage } from "./base"; + +export class PoolsPage extends BasePage { + async validatePoolModalOpened() { + await expect(this.page.getByTestId("modal")).toBeVisible(); + } + + async inputPoolName(name: string, poolIndex: number = 0) { + await this.page.getByTestId(`pool-name-${poolIndex}-input`).fill(name); + } + + async inputPoolUrl(url: string, poolIndex: number = 0) { + await this.page.getByTestId(`url-${poolIndex}-input`).fill(url); + } + + async inputPoolUsername(username: string, poolIndex: number = 0) { + await this.page.getByTestId(`username-${poolIndex}-input`).fill(username); + } + + async inputPoolPassword(password: string, poolIndex: number = 0) { + await this.page.getByTestId(`password-${poolIndex}-input`).fill(password); + } + + async clickTestConnection() { + await this.page.locator(`//button//*[text()='Test connection']`).click(); + } + + async validateConnectionSuccessful() { + await expect( + this.page.locator(`//div[@data-testid='pool-connected-callout' and not(contains(@class,'hidden'))]`), + ).toBeVisible(); + } + + async clickSave() { + await this.clickButton("Save"); + } + + async clickAddPool() { + await this.clickButton("Add pool"); + } + + async clickAddAnotherPool() { + await this.clickButton("Add another pool"); + } + + async validateUrlValidationError(poolIndex: number, message: string) { + await expect(this.page.getByTestId(`url-${poolIndex}-input-validation-error`)).toBeVisible(); + await expect(this.page.getByTestId(`url-${poolIndex}-input-validation-error`)).toHaveText(message); + } + + async validateConnectionFailed() { + await expect(this.page.getByTestId("pool-not-connected-callout")).toBeVisible(); + await this.validateTextInModal("We couldn't connect with your pool. Review your pool details and try again."); + } + + async closePoolNotConnectedCallout() { + await this.page.getByTestId("pool-not-connected-callout").getByRole("button").click(); + } + + async validateSaveButtonDisabled() { + await expect(this.page.getByTestId("modal").getByRole("button", { name: "Save" })).toBeDisabled(); + } + + async validateSaveButtonEnabled() { + await expect(this.page.getByTestId("modal").getByRole("button", { name: "Save" })).toBeEnabled(); + } + + async validateCalloutWithText(text: string) { + await expect(this.page.getByTestId("callout")).toBeVisible(); + await expect(this.page.getByTestId("callout").getByText(text)).toBeVisible(); + } + + async closeCallout() { + await this.page.getByTestId("callout").getByRole("button").click(); + } + + async clickMiningPoolButton() { + await this.clickButton("Mining Pool"); + } + + async validatePoolInfoPopoverVisible() { + await expect(this.page.getByTestId("pool-info-popover")).toBeVisible(); + } + + async validateTitleInPopover(title: string) { + await expect( + this.page.getByTestId("pool-info-popover").locator(`//*[contains(@class,'heading')][text()="${title}"]`), + ).toBeVisible(); + } + + async validateTextInPopover(text: string) { + await expect(this.page.getByTestId("pool-info-popover").getByText(text)).toBeVisible(); + } + + async validateExactTextInPopover(text: string) { + await expect(this.page.getByTestId("pool-info-popover").getByText(text, { exact: true })).toBeVisible(); + } + + async clickViewMiningPools() { + await this.page.getByTestId("pool-info-popover").getByRole("button", { name: "View mining pools" }).click(); + } + + async validatePoolRowCount(expectedCount: number) { + const poolRows = this.page.getByTestId("pool-row"); + await expect(poolRows).toHaveCount(expectedCount); + } + + async validatePoolRowDetails(poolIndex: number, poolName: string, poolUrl: string) { + const poolRows = this.page.getByTestId("pool-row"); + const targetRow = poolRows.nth(poolIndex); + + await expect(targetRow).toBeVisible(); + await expect(targetRow.getByText(poolName)).toBeVisible(); + await expect(targetRow.getByTestId(`pool-${poolIndex}-saved-url`)).toHaveText(poolUrl); + } +} diff --git a/client/e2eTests/protoOS/playwright.config.ts b/client/e2eTests/protoOS/playwright.config.ts new file mode 100644 index 000000000..97d3c2b53 --- /dev/null +++ b/client/e2eTests/protoOS/playwright.config.ts @@ -0,0 +1,69 @@ +import { defineConfig } from "@playwright/test"; +import { testConfig } from "./config/test.config"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ + +export default defineConfig({ + testDir: "./spec", + /* Run tests in serial order (one at a time) */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI for more stability */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [ + ["html", { outputFolder: "playwright-report", open: "never" }], + ["github"], + ["junit", { outputFile: "test-results/results.xml" }], + ] + : "html", + /* Global timeout for each test */ + timeout: testConfig.testTimeout, + /* Set default timeout for all expect() assertions */ + expect: { + timeout: testConfig.actionTimeout, + }, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: testConfig.baseUrl, + + /* Set a consistent viewport size for all tests */ + viewport: { width: 1600, height: 900 }, + + /* Set default timeout for actions like click, fill, etc. */ + actionTimeout: testConfig.actionTimeout, + + /* Capture screenshots (only on failure) and video (retain on failure) so they appear in the HTML report */ + screenshot: "only-on-failure", + video: "retain-on-failure", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + // E.g.: npx playwright test --project=desktop + projects: [ + { + name: "desktop", + use: { + viewport: { width: 1600, height: 900 }, + isMobile: false, + }, + }, + // Resolution of the iPhone 14 Pro / 15 Pro / 16 + { + name: "mobile", + use: { + viewport: { width: 393, height: 852 }, + isMobile: true, + }, + }, + ], +}); diff --git a/client/e2eTests/protoOS/spec/00-onboarding.spec.ts b/client/e2eTests/protoOS/spec/00-onboarding.spec.ts new file mode 100644 index 000000000..0739e0e91 --- /dev/null +++ b/client/e2eTests/protoOS/spec/00-onboarding.spec.ts @@ -0,0 +1,83 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { generateRandomText } from "../helpers/testDataHelper"; + +test.describe("Onboarding", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("Complete onboarding flow @setup", async ({ welcomePage, homePage, poolsPage: poolsPage }) => { + const poolName = generateRandomText("PoolName"); + const poolUsername = generateRandomText("PoolUsername"); + const poolPassword = generateRandomText("PoolPassword"); + + await test.step("Welcome screen - validate and start setup", async () => { + await welcomePage.validateWelcomeUrl(); + await welcomePage.validateTextIsVisible("Miner setup"); + await welcomePage.clickGetStartedButton(); + }); + + await test.step("Verify miner - validate information and continue", async () => { + await welcomePage.validateVerifyUrl(); + await welcomePage.validateTitle("Is this the miner you want to set up?"); + await welcomePage.validateTextIsVisible("Controller Serial"); + await welcomePage.validateTextIsVisible("Mac Address"); + await welcomePage.clickContinueSetup(); + }); + + await test.step("Create authentication - validate and set password", async () => { + await welcomePage.validateAuthenticationUrl(); + await welcomePage.validateTitle("Create an admin login for your miner"); + await welcomePage.validateUsernameFieldDisabledWithValue("admin"); + await welcomePage.inputPassword(testConfig.admin.password); + await welcomePage.inputConfirmPassword(testConfig.admin.password); + await welcomePage.clickContinue(); + }); + + await test.step("Mining pool setup - validate page and warning", async () => { + await welcomePage.validateMiningPoolUrl(); + await welcomePage.validateTitle("Pools"); + await welcomePage.validateTextIsVisible("Add up to 3 pools for your miner."); + await welcomePage.clickButton("Continue"); + await welcomePage.validateDefaultPoolWarningVisible(); + await welcomePage.validateDefaultPoolWarningText(); + await welcomePage.closeDefaultPoolWarning(); + }); + + await test.step("Add default mining pool", async () => { + await poolsPage.clickAddPool(); + await poolsPage.validatePoolModalOpened(); + await poolsPage.inputPoolName(poolName, 0); + await poolsPage.inputPoolUrl(testConfig.pool.url, 0); + await poolsPage.inputPoolUsername(poolUsername, 0); + await poolsPage.inputPoolPassword(poolPassword, 0); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionSuccessful(); + await poolsPage.clickSave(); + await poolsPage.validateModalIsClosed(); + }); + + await test.step("Submit one pool", async () => { + await welcomePage.clickButton("Continue"); + }); + + await test.step("Confirm continue without backup pool", async () => { + await welcomePage.validateTitle("Continue without a backup pool?"); + await welcomePage.validateButtonIsVisible("Add a backup pool"); + await welcomePage.clickButton("Continue without backup"); + }); + + await test.step("Your miner is ready", async () => { + await welcomePage.validateTitle("Configuring your miner"); + await welcomePage.validateTitle("Your miner is ready"); + await welcomePage.validateTextIsVisible("Testing your mining pool connections"); + await welcomePage.clickButton("Continue"); + }); + + await test.step("Validate user is logged in to dashboard", async () => { + await homePage.validateLoggedIn(); + }); + }); +}); diff --git a/client/e2eTests/protoOS/spec/dashboard.spec.ts b/client/e2eTests/protoOS/spec/dashboard.spec.ts new file mode 100644 index 000000000..5ed5ad175 --- /dev/null +++ b/client/e2eTests/protoOS/spec/dashboard.spec.ts @@ -0,0 +1,128 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Home dashboard", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Validate KPI tabs and stats", async ({ homePage }) => { + await test.step("Validate Hashrate stats", async () => { + const expectedValuePattern = /(\d+,)?\d+\.\d\sTH\/(S|s)/; + await homePage.validateTabHeading("hashrate", "Hashrate"); + await homePage.validateTabValue("hashrate", expectedValuePattern); + await homePage.clickTab("hashrate"); + await homePage.validateStatsCount(4); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.validateStatItem(3, "Lowest Performer", /Hashboard \d/); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(expectedValuePattern); + }); + + await test.step("Validate Efficiency stats", async () => { + const expectedValuePattern = /\d+\.\d\sJ\/TH/; + await homePage.validateTabHeading("efficiency", "Efficiency"); + await homePage.validateTabValue("efficiency", expectedValuePattern); + await homePage.clickTab("efficiency"); + await homePage.validateStatsCount(4); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.validateStatItem(3, "Lowest Performer", /Hashboard \d/); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(expectedValuePattern); + }); + + await test.step("Validate Power Usage stats", async () => { + const expectedValuePattern = /\d+\.\d\skW/; + const acceptableWattsPattern = /(\d|,)+\.\d\sk?W/; + await homePage.validateTabHeading("powerUsage", "Power Usage"); + await homePage.validateTabValue("powerUsage", expectedValuePattern); + await homePage.clickTab("powerUsage"); + await homePage.validateStatsCount(3); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(acceptableWattsPattern); + }); + + await test.step("Validate Temperature stats", async () => { + const expectedValuePattern = /\d+\.\d\s°(C|F)/; + await homePage.validateTabHeading("temperature", "Temperature"); + await homePage.validateTabValue("temperature", expectedValuePattern); + await homePage.clickTab("temperature"); + await homePage.validateStatsCount(4); + await homePage.validateStatItem(0, "Average", expectedValuePattern); + await homePage.validateStatItem(1, "Highest", expectedValuePattern); + await homePage.validateStatItem(2, "Lowest", expectedValuePattern); + await homePage.validateStatItem(3, "Hottest Hashboard", /Hashboard \d/); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(expectedValuePattern); + }); + }); + + test("Chart hashboard filtering", async ({ homePage, page }) => { + await test.step("Initial state: all filters inactive", async () => { + await homePage.validateFilteredChart([]); + }); + + await test.step("Click all hashboards (should enable all hashboards)", async () => { + await page.getByTestId("chart-filter-all-hashboards").click(); + await homePage.validateFilteredChart(["1", "2", "3", "4"]); + }); + + await test.step("Click summary (should enable all)", async () => { + await page.getByTestId("chart-filter-summary").click(); + await homePage.validateFilteredChart(["S", "1", "2", "3", "4"]); + }); + + await test.step("Click hashboard 3 (should leave S,1,2,4)", async () => { + await page.getByTestId("chart-filter-hashboard-3").click(); + await homePage.validateFilteredChart(["S", "1", "2", "4"]); + }); + + await test.step("Click all hashboards (should re-enable all)", async () => { + await page.getByTestId("chart-filter-all-hashboards").click(); + await homePage.validateFilteredChart(["S", "1", "2", "3", "4"]); + }); + + await test.step("Click all hashboards again (should leave only summary)", async () => { + await page.getByTestId("chart-filter-all-hashboards").click(); + await homePage.validateFilteredChart(["S"]); + }); + + await test.step("Click summary (should re-enable all)", async () => { + await page.getByTestId("chart-filter-summary").click(); + await homePage.validateFilteredChart([]); + }); + + await test.step("Click hashboard 4 (should leave only 4)", async () => { + await page.getByTestId("chart-filter-hashboard-4").click(); + await homePage.validateFilteredChart(["4"]); + }); + + await test.step("Click hashboard 2 (should select 2 and 4)", async () => { + await page.getByTestId("chart-filter-hashboard-2").click(); + await homePage.validateFilteredChart(["2", "4"]); + }); + + await test.step("Click summary (should add summary to 2 and 4)", async () => { + await page.getByTestId("chart-filter-summary").click(); + await homePage.validateFilteredChart(["S", "2", "4"]); + }); + + await test.step("Click hashboard 3 (should add 3)", async () => { + await page.getByTestId("chart-filter-hashboard-3").click(); + await homePage.validateFilteredChart(["S", "2", "3", "4"]); + }); + + await test.step("Click hashboard 1 (should add 1, all active)", async () => { + await page.getByTestId("chart-filter-hashboard-1").click(); + await homePage.validateFilteredChart(["S", "1", "2", "3", "4"]); + }); + }); +}); diff --git a/client/e2eTests/protoOS/spec/diagnostics.spec.ts b/client/e2eTests/protoOS/spec/diagnostics.spec.ts new file mode 100644 index 000000000..a34eaeed4 --- /dev/null +++ b/client/e2eTests/protoOS/spec/diagnostics.spec.ts @@ -0,0 +1,123 @@ +/* eslint-disable playwright/expect-expect */ +import { expect } from "@playwright/test"; +import { test } from "../fixtures/pageFixtures"; + +type DiagnosticsSection = { + filterLabel: "Fans" | "Hashboards" | "PSUs" | "Control Board"; + sectionTestIdName: "Fans" | "Hashboards" | "PSU" | "Control Board"; + expectedCardCount: number; +}; + +const diagnosticsSections: DiagnosticsSection[] = [ + { filterLabel: "Fans", sectionTestIdName: "Fans", expectedCardCount: 6 }, + { filterLabel: "Hashboards", sectionTestIdName: "Hashboards", expectedCardCount: 6 }, + // Note: PSU is the only mismatch: filter button shows "PSUs" but the section uses "PSU". + { filterLabel: "PSUs", sectionTestIdName: "PSU", expectedCardCount: 3 }, + { filterLabel: "Control Board", sectionTestIdName: "Control Board", expectedCardCount: 1 }, +]; + +test.describe("Diagnostics", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Diagnostics sections and filters", async ({ commonSteps, diagnosticsPage }) => { + await commonSteps.navigateToDiagnostics(); + + const allSectionTestIdNames = diagnosticsSections.map((s) => s.sectionTestIdName); + + await test.step("Validate all sections are visible", async () => { + await diagnosticsPage.validateAllSectionsVisible(allSectionTestIdNames); + }); + + await test.step("Validate each filter shows only its section and correct card count", async () => { + for (const selected of diagnosticsSections) { + await diagnosticsPage.clickFilterButton(selected.filterLabel); + await diagnosticsPage.validateOnlySectionVisible(selected.sectionTestIdName, allSectionTestIdNames); + await diagnosticsPage.validateCardCountInSection(selected.sectionTestIdName, selected.expectedCardCount); + } + }); + }); + + test("Diagnostics cards show correct modal metrics and metadata", async ({ commonSteps, diagnosticsPage }) => { + await commonSteps.navigateToDiagnostics(); + + const hashRateValuePattern = /\d+(?:\.\d+)?\s*TH\/S/; + const wattsValuePattern = /\d{1,3}(?:,\d{3})*\s*W/; + const celsiusValuePattern = /\d+(?:\.\d+)?\s*°C/; + const efficiencyValuePattern = /\d+(?:\.\d+)?\s*J\/TH/; + const rpmValuePattern = /\d+\s*RPM/; + + const expectedBySection: Record< + DiagnosticsSection["sectionTestIdName"], + { + metrics: Array<{ label: string | RegExp; valuePattern: RegExp }>; + metadata: Array<{ label: string }>; + expectedInfoCardCount: number; + emptySlotText: RegExp; + allowExtraMetadataRows?: boolean; + } + > = { + Fans: { + metrics: [{ label: /\d+(?:\.\d+)?%\s*PWM/, valuePattern: rpmValuePattern }], + metadata: [], + expectedInfoCardCount: 4, + emptySlotText: /No fan detected in this slot/i, + }, + Hashboards: { + metrics: [ + { label: "Hashrate", valuePattern: hashRateValuePattern }, + { label: "Power", valuePattern: wattsValuePattern }, + { label: "ASIC Avg Temp", valuePattern: celsiusValuePattern }, + { label: "ASIC High Temp", valuePattern: celsiusValuePattern }, + { label: "Efficiency", valuePattern: efficiencyValuePattern }, + ], + metadata: [{ label: "Serial Number" }, { label: "Model" }, { label: "ASIC Count" }, { label: "Slot Location" }], + expectedInfoCardCount: 4, + emptySlotText: /No hashboard detected in this slot/i, + allowExtraMetadataRows: true, + }, + PSU: { + metrics: [ + { label: "Input Power", valuePattern: wattsValuePattern }, + { label: "Output Power", valuePattern: wattsValuePattern }, + { label: "Average Temp", valuePattern: celsiusValuePattern }, + { label: "Max Temp", valuePattern: celsiusValuePattern }, + ], + metadata: [{ label: "Serial Number" }, { label: "Model" }, { label: "Firmware Version" }], + expectedInfoCardCount: 2, + emptySlotText: /No psu detected in this slot/i, + }, + "Control Board": { + metrics: [], + metadata: [{ label: "Serial Number" }], + expectedInfoCardCount: 1, + emptySlotText: /No control board detected in this slot/i, + }, + }; + + for (const section of diagnosticsSections) { + await test.step(`Validate modal content for ${section.filterLabel} cards`, async () => { + await diagnosticsPage.clickFilterButton(section.filterLabel); + await diagnosticsPage.validateCardCountInSection(section.sectionTestIdName, section.expectedCardCount); + + const expected = expectedBySection[section.sectionTestIdName]; + + const counts: Record<"info" | "empty", number> = { info: 0, empty: 0 }; + + for (let cardIndex = 0; cardIndex < section.expectedCardCount; cardIndex++) { + const kind = await diagnosticsPage.validateCardInfoOrEmptySlot( + section.sectionTestIdName, + cardIndex, + expected, + ); + counts[kind] += 1; + } + + expect(counts.info).toBe(expected.expectedInfoCardCount); + expect(counts.empty).toBe(section.expectedCardCount - expected.expectedInfoCardCount); + }); + } + }); +}); diff --git a/client/e2eTests/protoOS/spec/pools.spec.ts b/client/e2eTests/protoOS/spec/pools.spec.ts new file mode 100644 index 000000000..6ba2704d2 --- /dev/null +++ b/client/e2eTests/protoOS/spec/pools.spec.ts @@ -0,0 +1,106 @@ +/* eslint-disable playwright/expect-expect */ +import { testConfig } from "../config/test.config"; +import { test } from "../fixtures/pageFixtures"; +import { generateRandomText } from "../helpers/testDataHelper"; + +test.describe("Mining pools", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Check pool errors", async ({ poolsPage: poolsPage, commonSteps }) => { + await commonSteps.navigateToPoolsSettings(); + + await test.step("Open add-pool modal", async () => { + await poolsPage.clickAddAnotherPool(); + }); + + await test.step("Validate URL required for test connection", async () => { + await poolsPage.clickTestConnection(); + await poolsPage.validateUrlValidationError(1, "A Pool URL is required to connect to this pool."); + }); + + await test.step("Test connection fails for invalid pool URL", async () => { + await poolsPage.inputPoolUrl("aaa", 1); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionFailed(); + await poolsPage.closePoolNotConnectedCallout(); + }); + + await test.step("Validate save button enable/disable rules", async () => { + await poolsPage.validateSaveButtonDisabled(); + + await poolsPage.inputPoolUsername("aaa", 1); + await poolsPage.validateSaveButtonDisabled(); + + await poolsPage.inputPoolName("aaa", 1); + await poolsPage.validateSaveButtonEnabled(); + + await poolsPage.inputPoolUsername("", 1); + await poolsPage.validateSaveButtonEnabled(); + + await poolsPage.inputPoolUsername("aaa", 1); + await poolsPage.validateSaveButtonEnabled(); + }); + + await test.step("Save invalid pool URL shows error toast", async () => { + await poolsPage.clickSave(); + await poolsPage.validateToastMessage("Your changes were not saved"); + }); + + await commonSteps.navigateToHome(); + await commonSteps.navigateToPoolsSettings(); + + await test.step("Validate the invalid pool was not saved", async () => { + await poolsPage.validatePoolRowCount(1); + }); + }); + + test("Set up backup pools", async ({ poolsPage: poolsPage }) => { + const poolName1 = generateRandomText("PoolName1"); + const poolUsername1 = generateRandomText("PoolUsername1"); + const poolName2 = generateRandomText("PoolName2"); + const poolUsername2 = generateRandomText("PoolUsername2"); + + await test.step("Validate current default pool", async () => { + await poolsPage.clickMiningPoolButton(); + await poolsPage.validatePoolInfoPopoverVisible(); + await poolsPage.validateTitleInPopover("Mining pool"); + await poolsPage.validateExactTextInPopover("Connected"); + await poolsPage.validateTextInPopover("Default Pool"); + await poolsPage.validateTextInPopover(testConfig.pool.url); + await poolsPage.clickViewMiningPools(); + }); + + await test.step("Add first backup pool", async () => { + await poolsPage.clickAddAnotherPool(); + await poolsPage.validatePoolModalOpened(); + await poolsPage.inputPoolName(poolName1, 1); + await poolsPage.inputPoolUrl(testConfig.pool.url, 1); + await poolsPage.inputPoolUsername(poolUsername1, 1); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionSuccessful(); + await poolsPage.clickSave(); + await poolsPage.validateModalIsClosed(); + }); + + await test.step("Add second backup pool", async () => { + await poolsPage.clickAddAnotherPool(); + await poolsPage.validatePoolModalOpened(); + await poolsPage.inputPoolName(poolName2, 2); + await poolsPage.inputPoolUrl(testConfig.pool.url, 2); + await poolsPage.inputPoolUsername(poolUsername2, 2); + await poolsPage.clickTestConnection(); + await poolsPage.validateConnectionSuccessful(); + await poolsPage.clickSave(); + await poolsPage.validateModalIsClosed(); + }); + + await test.step("Validate all 3 pool rows exist with correct details", async () => { + await poolsPage.validatePoolRowCount(3); + await poolsPage.validatePoolRowDetails(1, poolName1, testConfig.pool.url); + await poolsPage.validatePoolRowDetails(2, poolName2, testConfig.pool.url); + }); + }); +}); diff --git a/client/e2eTests/protoOS/spec/power.spec.ts b/client/e2eTests/protoOS/spec/power.spec.ts new file mode 100644 index 000000000..a8e288571 --- /dev/null +++ b/client/e2eTests/protoOS/spec/power.spec.ts @@ -0,0 +1,119 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Power management", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Miner sleep status in different pages", async ({ + homePage, + commonSteps, + headerComponent, + sleepWakeDialogsComponent, + }) => { + await test.step("Put miner to SLEEP", async () => { + await headerComponent.clickPowerButton(); + await headerComponent.clickPowerPopoverButton("Sleep"); + }); + + await test.step("Confirm enter SLEEP mode", async () => { + await homePage.validateWarnSleepDialog(); + await sleepWakeDialogsComponent.clickEnterSleepMode(); + await sleepWakeDialogsComponent.validateEnteringSleepDialog(); + }); + + await test.step("Validate miner status is Sleeping", async () => { + await headerComponent.validateMinerStatus("Sleeping"); + }); + + await commonSteps.navigateToDiagnostics(); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToLogs(); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToAuthenticationSettings(); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToGeneralSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToPoolsSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToHardwareSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToCoolingSettings(false); + await commonSteps.validateWakeCallout(); + + await commonSteps.navigateToHome(); + + await test.step("Wake miner up", async () => { + await headerComponent.clickPowerButton(); + await headerComponent.clickPowerPopoverButton("Wake up"); + }); + + await test.step("Confirm wake up miner", async () => { + await homePage.validateWarnWakeUpDialog(); + await sleepWakeDialogsComponent.clickWakeMinerInDialog(); + await sleepWakeDialogsComponent.validateWakingDialog(); + }); + + await test.step("Validate miner status is Hashing", async () => { + await headerComponent.validateMinerStatus("Hashing"); + }); + }); + + test("Different ways of setting miner to sleep and waking it up", async ({ + commonSteps, + headerComponent, + sleepWakeDialogsComponent, + }) => { + await test.step("Put miner to sleep from home page", async () => { + await headerComponent.clickPowerButton(); + await headerComponent.clickPowerPopoverButton("Sleep"); + await sleepWakeDialogsComponent.clickEnterSleepMode(); + await sleepWakeDialogsComponent.validateEnteringSleepDialog(); + }); + + await test.step("Wake miner up from header status", async () => { + await headerComponent.clickMinerStatusButton(); + await sleepWakeDialogsComponent.validateMinerAsleepModal(); + await sleepWakeDialogsComponent.clickWakeMinerInModal(); + await sleepWakeDialogsComponent.clickWakeMinerInDialog(); + await sleepWakeDialogsComponent.validateWakingDialog(); + await headerComponent.validateMinerStatus("Hashing"); + }); + + await commonSteps.navigateToDiagnostics(); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToLogs(); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToAuthenticationSettings(); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToGeneralSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToPoolsSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToHardwareSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + + await commonSteps.navigateToCoolingSettings(false); + await commonSteps.putMinerToSleep(); + await commonSteps.wakeMinerFromCallout(); + }); +}); diff --git a/client/e2eTests/protoOS/spec/temperature.spec.ts b/client/e2eTests/protoOS/spec/temperature.spec.ts new file mode 100644 index 000000000..96c467fcd --- /dev/null +++ b/client/e2eTests/protoOS/spec/temperature.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable playwright/expect-expect */ +import { test } from "../fixtures/pageFixtures"; + +test.describe("Temperature unit switching", () => { + test.beforeEach(async ({ page, commonSteps }) => { + await page.goto("/"); + await commonSteps.authenticateAsAdmin(); + }); + + test("Switch between Fahrenheit and Celsius", async ({ homePage, diagnosticsPage, generalPage, commonSteps }) => { + const fahrenheitPattern = /\d+\.\d\s°F/; + const celsiusPattern = /\d+\.\d\s°C/; + await commonSteps.navigateToGeneralSettings(); + + await test.step("Change temperature to Fahrenheit", async () => { + await generalPage.clickTemperatureButton(); + await generalPage.selectFahrenheit(); + await generalPage.clickDoneButton(); + await generalPage.validateTemperatureFormatFahrenheit(); + }); + + await commonSteps.navigateToHome(); + + await test.step("Validate temperature in Fahrenheit on Home", async () => { + await homePage.clickTab("temperature"); + await homePage.validateTemperatureInFormat(fahrenheitPattern); + await homePage.validateStatItem(0, "Average", fahrenheitPattern); + await homePage.validateStatItem(1, "Highest", fahrenheitPattern); + await homePage.validateStatItem(2, "Lowest", fahrenheitPattern); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(fahrenheitPattern); + }); + + await commonSteps.navigateToDiagnostics(); + + await test.step("Validate temperature in Fahrenheit on Diagnostics - Hashboards", async () => { + await diagnosticsPage.clickFilterButton("Hashboards"); + await diagnosticsPage.validateTemperaturesInFormat(8, fahrenheitPattern, celsiusPattern); + }); + + await test.step("Validate temperature in Fahrenheit on Diagnostics - PSUs", async () => { + await diagnosticsPage.clickFilterButton("PSUs"); + await diagnosticsPage.validateTemperaturesInFormat(4, fahrenheitPattern, celsiusPattern); + }); + + await commonSteps.navigateToGeneralSettings(); + + await test.step("Change temperature back to Celsius", async () => { + await generalPage.clickTemperatureButton(); + await generalPage.selectCelsius(); + await generalPage.clickDoneButton(); + await generalPage.validateTemperatureFormatCelsius(); + }); + + await commonSteps.navigateToHome(); + + await test.step("Validate temperature in Celsius on Home", async () => { + await homePage.clickTab("temperature"); + await homePage.validateTemperatureInFormat(celsiusPattern); + await homePage.validateStatItem(0, "Average", celsiusPattern); + await homePage.validateStatItem(1, "Highest", celsiusPattern); + await homePage.validateStatItem(2, "Lowest", celsiusPattern); + await homePage.hoverOverChart(); + await homePage.validateChartTooltipWithHashboards(celsiusPattern); + }); + + await commonSteps.navigateToDiagnostics(); + + await test.step("Validate temperature in Celsius on Diagnostics - Hashboards", async () => { + await diagnosticsPage.clickFilterButton("Hashboards"); + await diagnosticsPage.validateTemperaturesInFormat(8, celsiusPattern, fahrenheitPattern); + }); + + await test.step("Validate temperature in Celsius on Diagnostics - PSUs", async () => { + await diagnosticsPage.clickFilterButton("PSUs"); + await diagnosticsPage.validateTemperaturesInFormat(4, celsiusPattern, fahrenheitPattern); + }); + }); +}); diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 000000000..74b64c758 --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,178 @@ +import eslint from "@eslint/js"; +import { fixupPluginRules } from "@eslint/compat"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import typescriptEslintParser from "@typescript-eslint/parser"; +import importX from "eslint-plugin-import-x"; +import jsxA11y from "eslint-plugin-jsx-a11y"; +import playwright from "eslint-plugin-playwright"; +import prettier from "eslint-plugin-prettier"; +import eslintConfigPrettier from "eslint-config-prettier"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import storybook from "eslint-plugin-storybook"; +import globals from "globals"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file +const __dirname = path.dirname(__filename); // get the name of the directory + +export default [ + // global ignores + { + ignores: ["**/dist/**", "scripts/**", "**/playwright-report/**", "**/test-results/**", "**/api/generated/**"], + }, + eslint.configs.recommended, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + ecmaVersion: "latest", + globals: { + ...globals.browser, + }, + parser: typescriptEslintParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + tsconfigRootDir: __dirname, + }, + }, + plugins: { + "import-x": importX, + "jsx-a11y": jsxA11y, + react, + "react-hooks": fixupPluginRules(reactHooks), + "react-refresh": reactRefresh, + storybook, + "@typescript-eslint": typescriptEslint, + prettier, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + quotes: ["error", "double"], + "no-console": ["error", { allow: ["warn", "error"] }], + "import-x/no-unresolved": "off", + "sort-imports": [ + "error", + { + ignoreCase: true, + ignoreDeclarationSort: true, + }, + ], + "import-x/order": [ + "error", + { + groups: ["external", "builtin", "internal", "parent", "sibling", "index"], + pathGroups: [ + { + pattern: "assets", + group: "internal", + }, + { + pattern: "common", + group: "internal", + }, + { + pattern: "components", + group: "internal", + }, + { + pattern: "motion/react", + group: "external", + position: "before", + }, + { + pattern: "pages", + group: "internal", + }, + { + pattern: "react", + group: "external", + position: "before", + }, + { + pattern: "react-router-dom", + group: "external", + position: "before", + }, + { + pattern: "react-dom/client", + group: "external", + position: "before", + }, + { + pattern: "recharts", + group: "external", + position: "before", + }, + { + pattern: "tailwindcss/resolveConfig", + group: "external", + position: "before", + }, + { + pattern: "clsx", + group: "external", + position: "before", + }, + { + pattern: "@testing-library/react", + group: "external", + position: "before", + }, + { + pattern: "vitest", + group: "external", + position: "before", + }, + ], + pathGroupsExcludedImportTypes: ["internal"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + "prettier/prettier": "error", + }, + }, + { + files: ["e2eTests/**/*.ts"], + languageOptions: { + ecmaVersion: "latest", + globals: { + ...globals.node, + }, + parser: typescriptEslintParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + }, + plugins: { + "@typescript-eslint": typescriptEslint, + playwright, + prettier, + }, + rules: { + ...typescriptEslint.configs.recommended.rules, + ...playwright.configs["flat/recommended"].rules, + quotes: ["error", "double"], + semi: ["error", "always"], + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "prettier/prettier": "error", + }, + }, + eslintConfigPrettier, +]; diff --git a/client/nfpm-proto-os.yaml b/client/nfpm-proto-os.yaml new file mode 100644 index 000000000..540bf3b11 --- /dev/null +++ b/client/nfpm-proto-os.yaml @@ -0,0 +1,13 @@ +name: proto-os +arch: all +version: "${VERSION}" +maintainer: "Block Inc." +description: "ProtoOS web dashboard" +contents: + - src: dist/protoOS/ + dst: /var/www/ + type: tree + file_info: + mode: 0755 + - src: dist/web_dashboard_version + dst: /etc/web_dashboard_version diff --git a/client/nginx.runner-protofleet.conf b/client/nginx.runner-protofleet.conf new file mode 100644 index 000000000..18e8c6d65 --- /dev/null +++ b/client/nginx.runner-protofleet.conf @@ -0,0 +1,21 @@ +server { + listen 127.0.0.1:8080; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Match Vite proxy behavior used by ProtoFleet. + location /api-proxy/ { + proxy_pass http://127.0.0.1:4000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + client_max_body_size 64m; + } +} diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 000000000..0a20a4514 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,10922 @@ +{ + "name": "fleet-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fleet-client", + "version": "0.0.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-web": "2.1.1", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "clsx": "2.1.1", + "immer": "11.1.4", + "motion": "12.38.0", + "path": "0.12.7", + "react": "19.2.5", + "react-dom": "19.2.5", + "react-router-dom": "7.14.1", + "recharts": "3.8.1", + "zustand": "5.0.12" + }, + "devDependencies": { + "@bufbuild/buf": "1.68.1", + "@bufbuild/protoc-gen-es": "2.11.0", + "@eslint/compat": "2.0.5", + "@eslint/js": "10.0.1", + "@playwright/test": "1.59.1", + "@storybook/addon-docs": "10.3.5", + "@storybook/react": "10.3.5", + "@storybook/react-vite": "10.3.5", + "@tailwindcss/postcss": "4.2.2", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "25.6.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@vitejs/plugin-react": "6.0.1", + "chromatic": "16.3.0", + "concurrently": "9.2.1", + "eslint": "10.2.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import-x": "4.16.2", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-playwright": "2.10.1", + "eslint-plugin-prettier": "5.5.5", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.5.2", + "eslint-plugin-storybook": "10.3.5", + "globals": "17.5.0", + "image-size": "2.0.2", + "jsdom": "29.0.2", + "postcss": "8.5.10", + "prettier": "3.8.3", + "prettier-plugin-tailwindcss": "0.7.2", + "storybook": "10.3.5", + "swagger-typescript-api": "13.6.10", + "tailwindcss": "4.2.2", + "tsx": "4.21.0", + "typescript": "6.0.2", + "vite": "8.0.8", + "vite-plugin-checker": "0.13.0", + "vitest": "4.1.4" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", + "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/js-api": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@biomejs/js-api/-/js-api-4.0.0.tgz", + "integrity": "sha512-EOArR/6drRzM1/hwOIz1pZw90FL31Ud4Y7hEHGWVtMNmAwS9SrwZ8hMENGlLVXCeGW/kL46p8kX7eO6x9Nmezg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "@biomejs/wasm-bundler": "^2.3.0", + "@biomejs/wasm-nodejs": "^2.3.0", + "@biomejs/wasm-web": "^2.3.0" + }, + "peerDependenciesMeta": { + "@biomejs/wasm-bundler": { + "optional": true + }, + "@biomejs/wasm-nodejs": { + "optional": true + }, + "@biomejs/wasm-web": { + "optional": true + } + } + }, + "node_modules/@biomejs/wasm-nodejs": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@biomejs/wasm-nodejs/-/wasm-nodejs-2.4.12.tgz", + "integrity": "sha512-3SGczq2LKHJw9TYhJEmNwgBY767wSu+nRZVkp+oDiczYn2u7Xekb4smn/r3KJpaxGKkxxtTV4h6RTwBUKzf8Fw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bufbuild/buf": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.68.1.tgz", + "integrity": "sha512-QDJ3oy4qZ5EVS2JYtmpE1n9FuaoABthxIddXB050huGddatr1sjHJSSAXXpLotOI18pW3KQ4zzU1x5Ms+pEEOw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.68.1", + "@bufbuild/buf-darwin-x64": "1.68.1", + "@bufbuild/buf-linux-aarch64": "1.68.1", + "@bufbuild/buf-linux-armv7": "1.68.1", + "@bufbuild/buf-linux-x64": "1.68.1", + "@bufbuild/buf-win32-arm64": "1.68.1", + "@bufbuild/buf-win32-x64": "1.68.1" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.68.1.tgz", + "integrity": "sha512-+Cu/2Kr6Add3s+Zk/edcF9QdpnrsukQkdR/z4fk4+qr6YZqfWfiV8f+s14I3h7qPrPnGeCeynvmZ9NmJ1BMYuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.68.1.tgz", + "integrity": "sha512-hvAs452aJ6io9hZKSfr3TvC+//16zW5y5u3ucsIXVkl5mkmKWSCkPbZwGpjNCfRGGUsyRJGL6rixxTgLKw2k5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.68.1.tgz", + "integrity": "sha512-GLCakHzZVKUPlAiJEPGMBLW+yBk8tuz6NNcoeQU5lB5AO7ks8V8x9cy4CQjne4YSl3niF1JtvAQckLKhEWPueQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.68.1.tgz", + "integrity": "sha512-lUMCULl3MOYQe0oAPWnqNVYy8pL+F3Jeq6C4sSY+0E9udaACMc2mZ32gYingaMop9O1qS58HjJbezFxxL+CJqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.68.1.tgz", + "integrity": "sha512-eRU3UWiZQthAgx+qFTG3EeJ/VeOcZzAkKYGt5ansOnOIJHBm+3RG2KqA+Jm8q3EFqB1XpVcGxPXnIu/qmFJXaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.68.1.tgz", + "integrity": "sha512-v3xlKzs3l2C+mYv+T0sYol05DTmsFKYmM5Vz8+AyrXdjxRwq2QH7m0arVWwxHX2MwyhQxKA+qqjoF8bCUM7xxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.68.1", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.68.1.tgz", + "integrity": "sha512-b62pwu+G7n5tF8n1QIoT85K7xgKJZS8SzdN020weOa7IVvMNHCDqMq7nrkz46fXCkK7MtD1YJ6sUp86sWSZPsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.11.0.tgz", + "integrity": "sha512-VzQuwEQDXipbZ1soWUuAWm1Z0C3B/IDWGeysnbX6ogJ6As91C2mdvAND/ekQ4YIWgen4d5nqLfIBOWLqCCjYUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@bufbuild/protoplugin": "2.11.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.11.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.11.0.tgz", + "integrity": "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", + "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz", + "integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.1" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.5.tgz", + "integrity": "sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^8.40 || 9 || 10" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^13.0.1", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@storybook/addon-docs": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.3.5.tgz", + "integrity": "sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "10.3.5", + "@storybook/icons": "^2.0.1", + "@storybook/react-dom-shim": "10.3.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.3.5" + } + }, + "node_modules/@storybook/builder-vite": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.3.5.tgz", + "integrity": "sha512-i4KwCOKbhtlbQIbhm53+Kk7bMnxa0cwTn1pxmtA/x5wm1Qu7FrrBQV0V0DNjkUqzcSKo1CjspASJV/HlY0zYlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "10.3.5", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.3.5", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.3.5.tgz", + "integrity": "sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^2.3.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "esbuild": "*", + "rollup": "*", + "storybook": "^10.3.5", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@storybook/react": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.3.5.tgz", + "integrity": "sha512-tpLTLaVGoA6fLK3ReyGzZUricq7lyPaV2hLPpj5wqdXLV/LpRtAHClUpNoPDYSBjlnSjL81hMZijbkGC3mA+gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "10.3.5", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.3.5", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.3.5.tgz", + "integrity": "sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.3.5" + } + }, + "node_modules/@storybook/react-vite": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.3.5.tgz", + "integrity": "sha512-UB5sJHeh26bfd8sNMx2YPGYRYmErIdTRaLOT28m4bykQIa1l9IgVktsYg/geW7KsJU0lXd3oTbnUjLD+enpi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "10.3.5", + "@storybook/react": "10.3.5", + "empathic": "^2.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^8.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.3.5", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-schema-official": { + "version": "2.0.25", + "resolved": "https://registry.npmjs.org/@types/swagger-schema-official/-/swagger-schema-official-2.0.25.tgz", + "integrity": "sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webcontainer/env": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz", + "integrity": "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chromatic": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-16.3.0.tgz", + "integrity": "sha512-PvXUpXP3l8p2NxLEYhMgo4kGNNBQgCgbslMu+O4Bb37ujiiDWk8QExX/Jk7JeHUewNgK2wg98rtw3gdfz3U+Kg==", + "dev": true, + "license": "MIT", + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0", + "@chromatic-com/vitest": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + }, + "@chromatic-com/vitest": { + "optional": true + } + } + }, + "node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dompurify": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/es6-promise": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", + "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.10.1.tgz", + "integrity": "sha512-qea3UxBOb8fTwJ77FMApZKvRye5DOluDHcev0LDJwID3RELeun0JlqzrNIXAB/SXCyB/AesCW/6sZfcT9q3Edg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^17.3.0" + }, + "engines": { + "node": ">=16.9.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-storybook": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.3.5.tgz", + "integrity": "sha512-rEFkfU3ypF44GpB4tiJ9EFDItueoGvGi3+weLHZax2ON2MB7VIDsxdSUGvIU5tMURg+oWYlpzCyLm4TpDq2deA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.48.0" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^10.3.5" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "dev": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http2-client": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", + "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.38.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-h2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", + "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http2-client": "^1.2.5" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-readfiles": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", + "integrity": "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^3.2.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oas-kit-common": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", + "integrity": "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "fast-safe-stringify": "^2.0.7" + } + }, + "node_modules/oas-linter": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz", + "integrity": "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@exodus/schemasafe": "^1.0.0-rc.2", + "should": "^13.2.1", + "yaml": "^1.10.0" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-linter/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oas-resolver": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", + "integrity": "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "node-fetch-h2": "^2.3.0", + "oas-kit-common": "^1.0.8", + "reftools": "^1.1.9", + "yaml": "^1.10.0", + "yargs": "^17.0.1" + }, + "bin": { + "resolve": "resolve.js" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-resolver/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oas-schema-walker": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", + "integrity": "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==", + "dev": true, + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-validator": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz", + "integrity": "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "call-me-maybe": "^1.0.1", + "oas-kit-common": "^1.0.8", + "oas-linter": "^3.2.2", + "oas-resolver": "^2.5.6", + "oas-schema-walker": "^1.1.5", + "reftools": "^1.1.9", + "should": "^13.2.1", + "yaml": "^1.10.0" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-validator/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-docgen": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", + "integrity": "sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.9.0 || >=22" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, + "node_modules/react-docgen/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", + "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reftools": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", + "integrity": "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==", + "dev": true, + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/should": { + "version": "13.2.3", + "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", + "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-equal": "^2.0.0", + "should-format": "^3.0.3", + "should-type": "^1.4.0", + "should-type-adaptors": "^1.0.1", + "should-util": "^1.0.0" + } + }, + "node_modules/should-equal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", + "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.4.0" + } + }, + "node_modules/should-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", + "integrity": "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-type-adaptors": "^1.0.1" + } + }, + "node_modules/should-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", + "integrity": "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/should-type-adaptors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", + "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-util": "^1.0.0" + } + }, + "node_modules/should-util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", + "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/storybook": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.3.5.tgz", + "integrity": "sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "@webcontainer/env": "^1.1.1", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "open": "^10.2.0", + "recast": "^0.23.5", + "semver": "^7.7.3", + "use-sync-external-store": "^1.5.0", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "dist/bin/dispatcher.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-schema-official": { + "version": "2.0.0-bab6bed", + "resolved": "https://registry.npmjs.org/swagger-schema-official/-/swagger-schema-official-2.0.0-bab6bed.tgz", + "integrity": "sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==", + "dev": true, + "license": "ISC" + }, + "node_modules/swagger-typescript-api": { + "version": "13.6.10", + "resolved": "https://registry.npmjs.org/swagger-typescript-api/-/swagger-typescript-api-13.6.10.tgz", + "integrity": "sha512-X+DenKK2txtI0LdNcpmWddcZWiWaf6erIZ+m9Wz7vk/yMFmPJ9EljSuX+dnJQ9YuzjV1imaRCDKsHA4CG2wUZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0", + "@biomejs/js-api": "4.0.0", + "@biomejs/wasm-nodejs": "2.4.12", + "@types/swagger-schema-official": "^2.0.25", + "c12": "^3.3.3", + "citty": "^0.2.1", + "consola": "^3.4.2", + "es-toolkit": "^1.44.0", + "eta": "^3.5.0", + "nanoid": "^5.1.6", + "openapi-types": "^12.1.3", + "swagger-schema-official": "2.0.0-bab6bed", + "swagger2openapi": "^7.0.8", + "type-fest": "^5.4.4", + "typescript": "~6.0.2", + "yaml": "^2.8.2", + "yummies": "7.18.0" + }, + "bin": { + "sta": "dist/cli.mjs", + "swagger-typescript-api": "dist/cli.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/swagger-typescript-api/node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/swagger2openapi": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", + "integrity": "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "call-me-maybe": "^1.0.1", + "node-fetch": "^2.6.1", + "node-fetch-h2": "^2.3.0", + "node-readfiles": "^0.2.0", + "oas-kit-common": "^1.0.8", + "oas-resolver": "^2.5.6", + "oas-schema-walker": "^1.1.5", + "oas-validator": "^5.0.8", + "reftools": "^1.1.9", + "yaml": "^1.10.0", + "yargs": "^17.0.1" + }, + "bin": { + "boast": "boast.js", + "oas-validate": "oas-validate.js", + "swagger2openapi": "swagger2openapi.js" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/swagger2openapi/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.13.0.tgz", + "integrity": "sha512-14EkOZmfinVZNxRmg2uCNDwtqGc/33lU/UEJansHgu27+ad+r6mMBf1Xtnq57jGZWiO/xzwtiEKPYsganw7ZFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "chokidar": "^4.0.3", + "npm-run-path": "^6.0.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.4", + "proper-lockfile": "^4.1.2", + "tiny-invariant": "^1.3.3", + "tinyglobby": "^0.2.15", + "vscode-uri": "^3.1.0" + }, + "engines": { + "node": ">=16.11" + }, + "peerDependencies": { + "@biomejs/biome": ">=1.7", + "eslint": ">=9.39.4", + "meow": "^13.2.0 || ^14.0.0", + "optionator": "^0.9.4", + "oxlint": ">=1", + "stylelint": ">=16.26.1", + "typescript": "*", + "vite": ">=5.4.21", + "vls": "*", + "vti": "*", + "vue-tsc": "~2.2.10 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@biomejs/biome": { + "optional": true + }, + "eslint": { + "optional": true + }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "oxlint": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vls": { + "optional": true + }, + "vti": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yummies": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/yummies/-/yummies-7.18.0.tgz", + "integrity": "sha512-fgldINrxPi20XJjIa1LOniyqHROm5HwDmmq0VZtQml4u3cMerm8H7H2BM8kMhHilgd8l7rg3mN4xt2apc2l/xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "nanoid": "^5.1.7", + "tailwind-merge": "^3.5.0" + }, + "peerDependencies": { + "mobx": "^6.12.4", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "mobx": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/yummies/node_modules/nanoid": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.9.tgz", + "integrity": "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 000000000..00eaec110 --- /dev/null +++ b/client/package.json @@ -0,0 +1,95 @@ +{ + "name": "fleet-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "npm run dev:protoOS", + "dev:protoOS": "npx tsx ./scripts/dev-protoOS.ts", + "dev:protoFleet": "vite --mode protoFleet", + "prebuild": "npm run lint", + "build": "tsc && vite build --mode protoOS && vite build --mode protoFleet", + "build:protoOS": "tsc && vite build --mode protoOS", + "build:protoFleet": "tsc && vite build --mode protoFleet", + "generate-api-types": "node ./scripts/generate_api_ts.mjs", + "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "preview:protoOS": "vite preview --mode protoOS", + "preview:protoFleet": "vite preview --mode protoFleet", + "storybook": "storybook dev -p 6006", + "build-storybook": "tsc --noEmit && BUILD_STORYBOOK=1 storybook build", + "test": "vitest", + "test:e2e": "cd e2eTests/protoFleet && npx playwright install && npx playwright test --project=desktop", + "test:e2e:ui": "cd e2eTests/protoFleet && npx playwright install && npx playwright test --ui --project=desktop", + "test:e2e:headed": "cd e2eTests/protoFleet && npx playwright install && npx playwright test --headed --project=desktop", + "upload": "scp -Crp dist/* admin@$npm_config_host:/home/admin/", + "format": "prettier --list-different --write \"./**/*.{js,jsx,ts,tsx,css,md}\"", + "format:check": "prettier --check \"./**/*.{js,jsx,ts,tsx,css,md}\"", + "bootstrap": "npx tsx ./scripts/auth_discover_pair.ts" + }, + "dependencies": { + "@bufbuild/protobuf": "2.11.0", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-web": "2.1.1", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "clsx": "2.1.1", + "immer": "11.1.4", + "motion": "12.38.0", + "path": "0.12.7", + "react": "19.2.5", + "react-dom": "19.2.5", + "react-router-dom": "7.14.1", + "recharts": "3.8.1", + "zustand": "5.0.12" + }, + "devDependencies": { + "@bufbuild/buf": "1.68.1", + "@bufbuild/protoc-gen-es": "2.11.0", + "@eslint/compat": "2.0.5", + "@eslint/js": "10.0.1", + "@playwright/test": "1.59.1", + "@storybook/addon-docs": "10.3.5", + "@storybook/react": "10.3.5", + "@storybook/react-vite": "10.3.5", + "@tailwindcss/postcss": "4.2.2", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1", + "@types/node": "25.6.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@vitejs/plugin-react": "6.0.1", + "chromatic": "16.3.0", + "concurrently": "9.2.1", + "eslint": "10.2.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import-x": "4.16.2", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-playwright": "2.10.1", + "eslint-plugin-prettier": "5.5.5", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.5.2", + "eslint-plugin-storybook": "10.3.5", + "globals": "17.5.0", + "image-size": "2.0.2", + "jsdom": "29.0.2", + "postcss": "8.5.10", + "prettier": "3.8.3", + "prettier-plugin-tailwindcss": "0.7.2", + "storybook": "10.3.5", + "swagger-typescript-api": "13.6.10", + "tailwindcss": "4.2.2", + "tsx": "4.21.0", + "typescript": "6.0.2", + "vite": "8.0.8", + "vite-plugin-checker": "0.13.0", + "vitest": "4.1.4" + }, + "overrides": { + "eslint": "10.2.0", + "typescript": "6.0.2" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 000000000..c2ddf7482 --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/client/public/favicon.png b/client/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..87b8cec63b532e74d03831f5f467d67ec9b366f5 GIT binary patch literal 2184 zcmZWrdpHve7az&BUbp2|l26Jl9}2M{xiguKUQhpAktDMScnomf6N`O}ezIpkV zhVbD*^YZ5K7Skcc)xH@cHsRobAJW8Q2)yvb1JAM2z9PAUZw%86S*hrb*@%l#35kGmBS)Zf+wk`KD|0GtpNo09R4Wo|IR_HQz%R)#iz&%hs97wY&nFL zn8UV8vPz}(XYf?Ku7Z)o(T@Lx_FpsNL%*0ZXE%2j+NN~>8AKMw_<4y0Sx_9LZKpG7 zCKbb5%FM;B0Izgc)qMGnYQSAqLBxQG&_>um1Fuf4c2;kTbCFIY*=1i!izg`1FP{#d z_grWdTj-H3sJOCOjXKdF708AXVDy$y4IZu=-e7zHLd$Wgx|1tMZz(~(>*Di37GDI^rpecv$zBl>07iDn&z$uVzYlQby??H zC|PDqyQZNoi(6)_vA#fgvzEH8QMc3O0&r80%L-+5Z3nT)&{snXWa%odX~)lLN_8@5 z8w|tGQtTy<^UVdU ztak!kLfAjz1bcVOwA<))&>tf%(x}5%K|eK6gpsS=HA(}YY}c=OODU+7iuhK`1?&$O zgp%~?Y(LG|7T#X=P}i-xCKpij8jWib&tB4vH|WV)GueRLXi=D#RL=J!9a`Q14!P^w zYFJU?(CT{1t7aV7Qw4qxqppg*}b_?BW3JDm6?)9Q$CDxySffe-BRkl=Xbyv>je3K#}Tg`2F_LF31U(l1&YD3}E>Hb#=2ahy1GSri@qg2h&&eoy zD5X>Ftwh8uH$8hCh8B)AvGpu8!T!l_us~%*V*lJB3Nn&lo$@uLTdnmKCoYj&@{8=F zxmS`I)O*g(G&&2=7KwvDn^P`BZijk$8=bt8OK`b7$pA1OrW^0 z2iAtOL}%u|p3`jA=q}8rTCR)EPz~}Xd%#kVb=P>x~sdB!0no=tcw{HiHJB5 zTH_t1#b_(uxzf>9VcxKWU4IuV0%hqpDpc;RHP0rvwu-d~2Y~|P@PXu})?0nPuE9Om2d#*nI*wt&zo2*OIu8-L z@;7c0MJ1}iW6>9Ez(!NT`iSo9FuRV1Dj@;wsKD~y{_jgWpWl8pNm70_l!26hwfSBdOG9$QscLz?6^O z@1IGfO;E9H_=;CvoF1fgF`^Nn>nM=-bMg>7#CSRy!Q8m;0}W zq*v%Y8VrOhRI9-`6FGJ1!y83pDNc3icwxMf~_ H(X7- literal 0 HcmV?d00001 diff --git a/client/public/fonts/Inter/InterVariable-Italic.woff2 b/client/public/fonts/Inter/InterVariable-Italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f22ec255493ebf94d16270d6279c1c9f2001278c GIT binary patch literal 380904 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

j2*lzMF$7|q6-@wZpQL{bn=0F#vb59BA}K%X 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� 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 z z#yyLGP!x$GRS{yLYWDwt;sq`jGC-g$4kWN#cvSHP^Umss8mtk$Po?)7GaB`lJ3eOU zx@D;`AgdWh)lR*#^=d8Brc1e!ZA*^pvRX>h)EtO${^fll9;91RA`@re{e8~&VKg`+ zL>cN-W}*DY4c_Oz#I<}*H^Qa~voHs;Aj=UvE{ZIJyP6^tiL%0MRS`@!!p9W8!e4GG za;H{X%jZ_hfQZICOnXlu{~m&p^sLpY`Z#3--9D;!2Azotb!P9Ew7iKkn6TzzxjLVb z9Ie(|IyU3e9`A6YlwF)&(~cXLfvM*3+j9E3P`e_hH)gUK6THdIies_o=5OAb*TbZmnSq7c4Jao!`%d|F z`?wJtXl-38rv{J51r094Ob3d>2y%z@L#NUiID2DA4}AD`mt8Wi^Y82{lp0;ULZ(W! z;adEw;g^>G)?a(vAqXcK4Mx3Ft?4s2RsGUvc6<#bA!SD(Of=nPSQdk4&$LKo$N?yY z34Qh2Li8$MX$%ie{_~WMGF3BN4Ajcw-u>`LLwVl4Rw~OK&30f1^p0-zx`lzB%ykx! zGtT5<$WG)c%L}^UrZXAa6-37B$D^Jz2u~hd9uKHi?P+x$1S2-J5LB_;#YBuJDctC< zYjq&M4N``b5hnFFUC=M;O!lqRU7bc54*z{L771!qTD38=+qi$0e(yE6LpR&1q~}Yc z?v7}Fo-(!`1~uV~dE@RTT}|-Cd&ds#{Dk|2F?SGJYclPM*<@}MV-hJf%)1BYDbpmG zbh?bs04(e!z`ID%+Uv3}^BabGd-8=$h9MauYN}e#!zA$8%Kn(WQa`Sl!Z|LtP%pde zSBGN7{4DC{LmJn-l)^flnKX6^wM$Yr%YfwdeCK)1518mYx_9qR2YVasi|ZEkZOS^q zZ*o?1<_2%H>MNr7H@ZGAdbQOc2x`i%tyY)%c^bv|{rpTYz|IQ1@_QRzQhHrS0(BQn zEeMUO+`^R@GY|9Lg*s9nDGwe7g@XLrtea1H(?8$VFT+|0==pvjIA(3 z8H`*jCejIeuebw&4w;Opi;Gf-X7h+oWi=057m|-#t=u-+t?wx>-i7pk*cr6>I#n3A zA-DhcKT@gE(%zi2ysxlqy7T5W{4)p)ryM>6@km1oQW48I_sKWkEC%bDg=%AE6gJqZ z+Wro^ZHjUex2E-yD`o56&|GoHV1YxYRK)qFTJ=h;r$cmYY@ND#wBK!4HxI9r8-tC; zKm1T9SgS?> zB$+T7EiOW6znPD;v1^m|Yw{q84G*POl4kK(*pPD62eR6tHY|n1n384jxRrHW)^F3f zg<`RgvGh#7(rkAd87MF+DM$<^B4nD92y;wDCoFu>4lX?4F_wD2=A}rj5(E8NPD;WU zD@j0Ue1Ns)c$8SJ-SG0=HY6{fY|GpoGDT5A3@T*cgTE*J3*lOom}gicC6W=OR=1%! z_%uY8A%e-JlQxB0%b)dwtxVa;h*?kZtM(8Be@^q-s8VF)`#pf3*Ey&4|dk+?IQCM+2e(0%jJ1 z9=u`hTi`RpmYE5xXZDT1(6bD_RQE?wagI(h#UiArgo64S|8@|f8*=a(Il{aCN98>q zDTFjRU&{EkzKx#fetmB*f=$7w1B!lF4X9W26`9QRe8x~i*wc+?0y zKT^8I6+SK>3JWHue6ZeLcjli~i_K1%hFcGB38{o4B_IeebY5S8nn~l}%HQ!RhY!xH z%>EbQnKo0|{Pr2+(%P^__i2E+_r;cF+bI87%#+)2CGoag*_3H6n@Q_|yfab?(vM52 zy}0CW|MDxWtg|}PAAV|2xq-^!b5q2mtn?ZF|K+#vH#g1Z|93VSSIZ7Yzsx;mqtUP9 z$)El-8GQ&w!~HLfzx`=6`Ry0LMDM*RNY$Iy*8V5*vT=_QKUF)ekv*kt?jC>o!7}!l4#C|yR_QSqN3_Anr&G3nTTe5PNJ60sv@w;IOi(; z-XNjr^rvcUhz*)2*2ahuXpQ`j)%VrncZQ<$cPk#QY zcmIrIFE>hMjCp(2F7oi4&g1U9$4@`}-nU3`e9hXIWNy!RRa%9&Y`A588J*3GKE|-^ zz3#Msv3~s~PZe0Fq_5~kw27-Ld2yq8sCt5Zb)b-V1KIvr=(O!u)|({I2CP#NjkTsR#J;x*2T2E3dpF zA1QoVOp+?wY?CEUl=;}mpRNvXdxqezTq_A*80U)O@jjlyxKg8w3VYC0@$Ta{DU(0g zR(zC*-g`Z}ojLKy`Qij8if7JEtM!IHxNU7f4V7!^>{v403C&1JR18fMTD6?f8%#}p zs!LL|P+s_nyvex7J8P0ytB3Y6a)8by)zF1;2W=yYwGMR9>4)=1aVN~r&{~l1d!t_x zS+g$a))T-Wxt3c@ao1AgX zv*&Q;Wy+3_JUBe?q{Aov1ilf~jvU?ZesI5Chc{?n9(WF2exKE1Jq9XX$B;J9YBcD; z>p1~_GW6il<0ISsCZpFEv>!4Se9zgK&7ab)%i$5;N?%x&cXf_q4^En?!xUCLHFmj_ znYr!{EWW%^My+FRol$|JJumQqFRY3ocH(bW<{jtyrnm95zRgcXqqOko#9_l?Ge*bH zbl86|uX8zQ8q25CsX{8|(b-bVk2Ca>n9$@k!WwaA>pa zv?xpt&#Ci>Hf`W5jf!n+6>SV&Fy8aR^v#w7yID9j6-KdSjW1^aUQ49{AeK+y(2M^P z7ZPMPU%(aLiVQwfYl)OBHi0ZG+&*brr&76OP|Vu^o6Ds`5P+yqTI);8jugP*@xUk% z!XlFcfeW-x(B|PCg-des1d$?SPi`{|oNszHNd2Czv~NoG@r=50r72KY)UrOg**%vT zKDmXmUwxkBA<>X;U+qWH-&ES3`nG5~8L#yuovX2MhI>5HiQ*>ClwQjeAy+m{5QI4^ z3ti4oTE+U%SNQ|6K|oi&s|K9lz6|DDqn}}cdxlGtP`*AlHj?}JD)@T1e00}anm4-n zc>QRRwjWuKIfut-6W}2`>i3OLu%jQVeTvp@{G*X4JLk#wX#Ye*f;m!94fghLzU%wM zyLa&s(F~5~1r~}B@)*+X02raLVcYdg0C@ezU?XEj#3=fFuw;b5+wHs)0ah-pzI3HE zl{0CVz0e>6duGPN80wk~Iyp|swA)rXU6R2`0qAOayh4Ma2=DHDMszTXRcy_T^rD-6 zR`b19-BzkUJkmr_Y7~}gwPm+@`)X^V*6x=g%eG&%RRvt31N=$z-d6bNqWjS?`vc9! zP2602m*0=fVKA+?0&1aJ!Uk*)(Dwha)meGg;onm!SAFFcgs?)P(E0pIev*&W{^t zA@8k!+OUgTzOHH=;gNBDbc)?Fd15)GjcJfo>{AH=lWV%v0@Ax*`T5wqSq=on>r@0bjjZ z>+l?B-I1T|qT^FQg&4v(kgW0ju{Jq;;K9P`J#p~LC(+7L3M|S-y9xs-41xVzGy;x7IDod@rqz042c?cUo(=tjl|zgF1eN{)90G^3+}@0r9)zy=t3?sDpE1~Xn;L0=w?D3`WORi2*8>U zdo33@T;Na}f?r$QtuXe1GBlw;79^+d9tM9J>53QbZpJ3;d~E5%JnudVlH;lj5o;0+ z6_$e=#N#NzrHp^2GbDT>5Wn8sL%^=E+OG^A5{Ojt$PPq-NBqyOL+@}5iReBoH$Cpr z7LqstE^scQaU*_M@S_m~K8&{&T>Ajt;3=z6VCRZ zjZmh^iS$YFrgD2fXFs@7W6y@ERw?H5x#a?YT4qhUj%B-nlCn{cq)7o4KK#zPr43z9 zSk7Ote@GYM#aQ9VXULWwHdb95bbYSIzR3+=S(3Ps>X6zWG-)>=Ns-?+Pawb|x`IrzL$UU#*5lGdWS}UyP z@R2#p81A<<-DivPqgbw zbp;Tl%D=dAk(j;sfbyN@PNt zD{Gf|$-*hu6H*_t2>0({kwqiBV0+n)Uwv)9TB}wYhC+V1lB>mRty~fv>kVCgb!A6r z7~)Q+*&>HH7hynU39N7eXsN~+lILeNQ&SYxq^FW)mh84N%q*XU2A|2 zk?h^116bRyhUh8B-2C>YX$G~Y8@4c^Gdh;8~ zeLv{yFQ_d9(sAFT?d?UWwo8m_vD)_WN)Wc<+z3V&-7;`tk0)4}fK`FI4m)5=*2Zii zicLNs%h-U4D9Q_%?-ajA+q?_p4o^tI(XeVxMo5#iZNl`H@@Bi z=Fykg6*)@dYn;yc1lA(N2=T7VJ%QE71CHBp+< zq0#eQaM!z*NykupBG1qhJ_kqK_%*Q{MBvx(izCz#(mp#)6bS>0NdHEHLd_QBrZvEw zlzWm4A*E<~F{Zh3!MdCA0yM>KM^Z&UB%w4QOLx(YF@J1iGhE_vp)#Bxpo+oF_+9b| z`$Pb1e!o`{6r>dG@$BWn>Jrt}N$2~4Eb8p5K916mRK0FVMhlHKUe^YSJVW)FI(XPx z@gzY2s$3JEWw5VC9Pzk}+WSJaK3d6?R|E}61IJ#OqTu@y1c_N9J(8dj^<$7+Ol6>{ zs|4yM$f&%ThpMh8sCA9uNv9p@C)WHsO6$o_QU?&lKI`a*K#dM_ZcW^9e2}cj;Bp`z zk`EsyukiVs!1+y__&9p-^ooMqYB>rtx%5phE#jrmw!s*AgExHg=?nCLBlx!$_xoyc zzzBGs0{+ddr#+~mPvK{8&L6Lnfi|#7eU9U>eN?ynj9*=A0w;QkPfGCcIvM1?6=Wg%(rV8}?;&ti(G(V>Q_um@SXz|D7oC=AT`=pv%orx!H5sz!g;1S|K=aPL)B`<0@ zDmMMguv)s?m0CK2IMIxRO3}_xmT{pic5A9|;y^QdHu0aUF{Ua;8SN@_gxa4zn_IeN zAf48JPpvkF+;4LBfxihnub^HKo0>UdLikmlfpn^qI5C;MY3f zC3+AD=Khy@!L<{uaSLfs4gb%js4rUvBmKa(0J?XAsuL7 z;@QWaxLUO%)Cm<(iol@)ovZlly{X9d6brgrVS3F9KL6g+$0H5N+8`bY)en8=l^8m& zefA%sdzv<2^XdcFYhp2eh^7<|~-cJyazmEM~j^5e6|0wVX zoxvS;oQg4gC+3!roee=aDu7(8BjA8pq*q30opZ*(=oA@vo_-KO%v=p~2~aE8YYjDg z9?S}r;DZ_FSAiZ*1wQo{=RS}}K#Yu}sB+cuZ2e;sYb_1Ocsp2u9L_~;X!5h*4dvh0 zL5tLrhi2$?GWN8B=>T#@YD(O85P&ehM%z|CoQ}qF|M*3bNKY^?^25Mqz?guk%LE~! z_KC%di=s==B!Pmrr%o=2VOCoxj>K&@zC?%&WNFm6v8JfRy8N{63b)s5+<6+cA<4@^ zP0PSW;$|jXTcQaccz4tnna=FJ2%!Gc8~@wQk8$6X;?XmR`cP~-mW`47Qm&3^oJ6`u zBq!1=#R|M!tnO)WDczE}F~I-g75D_jkVhegH6>MQi42=7;hsgDJp5MY5_Pdn^|1!k zJ(mYv6C!}$v3?M06GaH0KmOT;ufO`aT~bcF(g%CnN1vqEGrWvDKQsm2 zzNc z_-d7Z{q+bZR(*#3Kge?rSQovjFbgQ_;xpFw#&OK0sM2e``gPALl~Ki7>=RRh{kqcg zp{mKs9MaU3#0-Nua0ViRR2(fNDq4!< zXWi;-5ku{VaY^4QpPK)T$X~qbBkVM+b>Dkme|-#qXa{jncmDN@V?Uc0S&I9U`%@$= z&MHD0fnQ9D5Qu}T^qQj#d*dF zKLn?Bk@uk}yFg-tM31P@4dlsmxb3css2|S}Cvi&dsz0xx2fCB=ZCM6kk7E*qFm5o- z?7y`e+2ZGC&PUum>wDKQ=C`>o_I|VaCEs|C{-2reH~zdaPgFh6H+rdU7BdbJj4s|IFrG7S+99ot)&4w^(V;*iQT zWxiFbOomrdc=~K3E5WOJhs*V?@xe`#(yVkwU;ULU@J@2dsTbYj;zh-{WfM5+s{Z1{ zZ~xy_^`vl8h3I%=_raJme8u>ZZYzG)EqJqfliz>9|3&t}^ZTmRn;)CaMZDV~>ZH!& zyEEJBGalyVd4&wNhQ4m7e+9U+HYkDw(alFO%5LcXF113zIR*x#xL7 z(v;yvO(Z}b#hx2XZDa*hxn8i9Vw6S5YA|SEX)y&uVoRu}y%UC#)N>iu%T`J{m${J2 zrg+=hpkWb{wS9lIkxs;n_owJFmAZ6uWQTR6og#nmBCUN4i zuPz@Ie(}nY#IKBoJ6Pd8<$H_dh>JGp}$#bi!17ig+s@q}Kf)lPIgrAJy`99fHO+SZ0*YZ5WKP8)7l5MC38x z;MSYea#?fQ7%8^3)q8&nqlo9bNnmDylB6CLyOng3{khSO__vQ#CzW$MqX=2yge;2z&fVM)-5_M~hc3N-ovCkb=Wq7Hlz@#0u z`kOOEL$)%-7G@|mM3#M7x3OH$qHXlKXk{9{@q^qrc}-Ro5a+<&}5IR%TqbD=9*4)(){L${0%yraeM4id@>5P_2!+EoeBn`do)DQ zsxEy~yvb-{M%a=lky$@$qHH>-TNV{0TYJ<$8}lnH8ZW1&IBPHpz|2GYZ97PZLd8Nx zC>Ug3HKuPD5~(=f5cO@m(TK5NxihQHJ5jwp0{KkZCH?UXUdR32rmIYq;l3%8c zd}OEOr8-h9*aN4qDwTlXd{pJM>ee@2E$)NHO4<*X&yx*Cy7OO?0F-rOiA(SN{~}V0 z4CZwm7%w}l)iU)vG)#jRta$zO7^{Tgn@Lu(k~KJRf4_964@VDV6LAj$KZ^m_beqlE z>6=TvlG%T5!O455@PBlfA~@vJf62WYm93M+COzRngg>!Sax71S!~rwe*e6ZsP5k7_ z?$+da4%B+KkV#FD{)+|K;0J zk38#vrepLz!&t}^#C#3t6*K}JUv*JOF&icg(2?8gr~q54?1fBFfe=Cv3?J@x1)o2U{l&-dB9G zb?VgYd)r=Vf!3+av6-i5BY#;Q_$ctfmC)^j{c=3j_0G&B8Ztsl8x*9q{($9ZWr@w8 z6xmo^K;+=k?+Jmv>A?U zJjV{3xv~4^XfW|8fg>Tn#*(8`xw}cpF3S=B&#LLE`CD(9Mi=uBO!=}f`C@fi2`Vqn zi^f{bbWJ{mU3V;1HSjL=lV7f*ez;!wG&wUsE%qOKuuu)CZD=Y2)EW|coRLBjrO|sk zB3rL#)-L{PpICuvX5KWiena*1%k9&fhSJ3YYmS>}&TTtX*ChmK^}ck6UB#C(l((WYaFo6LA z+~vDnX%9f~3^5|e4e=dnQf|($oW!z7 zd0scjZT3)UpWZ4L^LZoL-gUojL&A2%qJZ#-ju`NSQ-6*lf|-K@2Ply8Qf%E{8;nyK z=p3D3N7MbmkUIZ;K8!xw{76*U*c=Q5xa*+ryO%ulEItvj?H~q0siBj3w#@2Lb`@KE z7JSgR>J&xO`QT#(RPQ}m-oq_BsHnuy|HJU3T*{!TP;c6qbwoEQJ${f^Kp8dQq)Dcek-TAXJ2PkwxcM!GAT_;xZ<$D%SSjbV_8!jts%jv^JWNY zs=VVYWXQoQ=OL9Dqajpcr1k|JY7QexJZ6@rlWjQp7j5h2Mb*IxZRmC@JM@0n54ZI+ z%XS5D#{D`ZS_ovaj-qHi=8ATY0NM@;vvm8JbDT(gi%{vOuZo5{BB%ZST$c>tCfM_K zMZ3Z4Ej@mNhYIn|HP?*->bSs9M$k14={wcbOl~XgHtjM_C^zJjGxLB4W+W|4-z^h!L=zHA>AdkxMOW~#_Xsv%%rjlvG5 zuoYFXeSD5!;73`>hL^dG(LaP#2D14{5uj~y%EBYuA5o~|Xg@(>1-#ZfCqC-j`%!0c zURX~_hL3Yllbfqub#sMc`S)M z!Lar{djRdqyhXBmw$#7-_4FsoUE!N1luYo|g=po`rI~R_{eRz8r9Pv+~PWh z#I~-F^G`|&yOI=E@wwR6I^gz#VEh@PescWG%fH(?tKC2AL(o}j-@|xX(g$WS&#g4+ zU|~V#vlZH_XR{5zqpX?Ws%J5G4bKseIa{?IB1nR!D1wIEJDT4%MN?1kBXlHJI36~x zPD&@PPE$5Ngs#g*5fL%wF5gBHlNv{C_m&E1FbA9JU8+GjFa8uqomsfgpDc^nLH5hqlra5!(!W9d1D5eFWLz{j`FQc&GK` zdKzq6ripE-=BMIqm@DrHwb&VZmSkpSnZK$nz5GD0TACg4?zICAVZw*Mk{}NqRsDqj z_I?R$DJDW&SjJ2uD1@*!0WE&i=TpMvm$;q>wviQ%4dHy_Cl2xvQ;0PEU;WR>-G~4y zx#wj}@D@niS{&=pBq#!luA>&CkY>=mdV+?jtuzNcdWJLUbq#TwMI4082X(P zKoN_LrK~a|S}vJUs8r8LqKTpFM+1@ak2+}#Pd9)^s+#00NdJjq#?vo)C~7{dYh-qM zV~dbu`G_aQr{8nqNaoeX0_X8hrh)z~=k`UM`b-)kMZeR_$R0{J)9@uMFueYG4`r{lHxj&BaT;NrHkwxQxN z8VtLYUft-PN|4apSDD@1&NQ|0#%_0LVsjjRp;UNL)Mp3_>t~T)cRb)6rIe1(8nbQ_ zOT~ykgmM2gF*BJK$-sn22BL?8DYHnm$XRn(KfRo>FO#lY5mZ`tGspsAmdKITR!MnK zNY{5opz?A%RDyp4^KUPy6Kr5ivOM_Z1eZ0NoDYAIxtlt64w=nm z^ppxj%o&0dsUK|VRSLu4z!<7v&;^KrS_SkXA^VEBO-|AT!_xHnFisK0*>J7Vj|R&_ z;MdQBGHuyAu{O46J$I7hQJGbAF`NKVF(NK?N1Q>#X=go(oiFByk=p~Kd^kMRO_dr7 z+N}q$)x@dL(o$-Zzqzym^0p)OB9UmH$gPkYGe&{msT@oa6qUN8?PPaK(y&Wluf$=g zzvc~h8!H_I)^;MHY5-g42z>Nm#tYA0{Hm+ZBY;1SKl#p4J=SVGT+lXb&+ELv?VPf} ze{$|vQ)>!- z+m&PsrJI4OKI${RA9igqS2WWWR>aNW!MJ`a=!jaISmOP*JugwykkFBUNZl}YWPfD7 zb|Ujk>Vw`z;f|8Q;Wf;-O?j|ZW3ohJr74~WTCgMC9ejt8TPljn!2DBDoFmXUMAxlm zui-;8fsy*w80DNQD;}*3{*g2{?Bs2-o8`hCj7Z3UDFnfq^!h|j(p1Z5@ExgC6H78y z`OgrzUQ(uMmNB}1=ShLMe6H(rq(x4X?XvxHA(S;0L=P^(_>kk~G@xG1A%tOG{tH0 zm*Q5^kiQD|nq?2~qa9uD>(oX{2))27)Q#v6ICjERzkYi@urWNs|2A9`6F$iKn&Jz&`JR>lUZj$#RwK}E+>s^UEj z+Tgbg?V68A4*^Sw6mYD3dQFFqoB?qqXCdy6DQV~;dv(1wUEZfg#=I=WJG+5}*pg91 ze$SSw+5|Uh1;_VECEcQ4(3p4SC6e5xQ?2zo zhG6ro`bsW3xZXfdsw3024L_kbc3*Kuww5l9(qL^dGB+HR{*Q1HQ}3KQSusTW=uWen zw>0?0H^=lBe||}D=7*X3 z1ihJIVvk5$YJE=bXmS5pNN*xmpSN5h4Lvb-rl(v|Y~P$5V*It=UyfxfTyjLewk^Wf z4(qQzrnZI*$&%}niCqq-HBy#ffXR#xS;u=#Z@@0?My<4;X573eEA>XBTG%H+6gMRtMF2oyFxrC#y}MB<_ay>K9s`5? zFxDGF-4&SF#wF`}uwx;6tzqPPY#R$k8o_5b2q&zYgQVEuXa>QyiBcqu1kig^k7a^r zH-guLhMZWR)27mxYuCJ=v`4rHO4LSOpIWJh8c?j%L33~+#IVtXsoB#M0BH^yk!{I{ zw90B(CnMUC$KcxC(}a{P3aTSd2So^*Z$AsE&r$@^{eCWn&C0ca-|dPPYrEuGX-I|x zcJ^SilP<4>$0%wAEf{87v06$jX=Ljfn>6Wb7sqxw$S0@MvV1Oci*ma+Wi%O#T2*B^ zSL6OxcQ6wZS+>r&q*fGqI`kjbPJ6KccO|qQES@t!NHAQD#gtIEy{g@3SofgF?$2-P zccd%gx2%f6DEg*rh6teSa<&Mlf=pBFGSsvTSb?M;N)sCADg9Rm@_iftlg8J5;-bjOJRP7u~p@!F8{bo#f-A9Ptc;+Ro+Z6>`B+KmIs3*=6shn1z&}$sDni5V#n8);v9zg0};dHn~}muk7Pr zjxO~_`Izqevt2nm6HoZ$W69}6_u4F!1oH)ZwswmE?L{Ti`x+Ux#I!UK=N@q=dTvHT(-(nEAR7JA5zK(Eo zEq-5r2$f*8(zZ2*1&&SAa41Tzq8C{kQM3GJhImfag2s2Hs} zIn5iqAfd(KUI<2aR2CeQ=9}f2y>Gp7w^Vas0=a@=UMu*6s2y4r5%@_fXC2L~F=1mL8@n6Jeq`W~OYNw(2!1~@uz=|51YCxE7qlO9?>=_EniPP=fD;F^5Yu*8YlFy( z=71{<@aj9>-$YDe*FKp)kM^JWwb7vSulKvb-n*VlOed3J15Kw6f_l8Iq7;*O+j#o= zimgsZSbHu9)~A;)x@^tJ&j`YrhM{7Ek+fXjmlmFXQrtcs3kW%J-Z^)pbuhPhmG|N)5?^%4B&zlK^=9*`mfs3C5-@$ZfBKeIKg+r5>R{ZP%j4q! zNkF#0wKnOtv-#4nIBO*dBJ}(x*YISJ_z159*zkrm=}Ns>%SKbF4hp zRWZ`KxttQVRX{lY^2-DuvavziqlV@(epTI1zZ#>$Q&v{;jw z%#Cz|=Kovfz8<%3>BojoTuRK;_VAmuOc#U1WXYA(Ii7!f*V&_68JSbMSRVgy`?QIM zf(6xw0&$TJ;CaDfe0*THw_lw{QKZ)1#h1NO0wVI(FUo3y;l5D@qI9~sS(g|i`OmYX zfV}kh$N02*D<2dNCzx{SrPkyb5A*yL`DZm$X1}|za{$r0UTspGYCv?>T1-W+>iE?y zM~{V2a(>gr$ruUIGg02>7$Vq`It%l(K1m5-!e}WoXuz1EtU1qTipKg{%VIUU65umx zzB=+~Paa6&QH(a*arwZqD!`ln7+C8SV@`wA?F4!Z2?2{V4zx;6CLqD1TR=7g3?Ynu zVj%$P5wb@TgNF7I0MNkgnr2ly1^D5weK)Rif0c2`V4Y@sT|lg@v1f_>#N-(?q1*zF)eT4WO|0Bie1s6RTitFW7`nD8n0qBz zX<;_KVMbToYF4Pgb%J=y3*NN5dZns0YN@@fBa#L9)AAB#eZA1q>E)|y1;)_r6%!;5 zLCp_p+C}i_`YSSNcHaH?V?2c9o;{YneO}V_SR%dHb#4fRrvVi5tv?W*7oyav+GSW< z$qe!Ba3&YrGp-swt8i-u(0j1G*=xmdJI_WByfW5#Rpi}R0p$- zc%|ucEV^T%C(=h>PIlCdh`^C0;GO{1UQkSn%MaZkCuo<4rjc+Hhye>QKrv|EliEd1 zM5J<^!BvH{X6z=tv_kq5qc*T4Q`hjpj-(b(g(D8W5$d&vO@|Alk z?Sa$fIkrEs-fD3j*aBF#N)tP-)2hsqpio2-EvV(F-*a%kjmlJN72Wk1!f-y1)3&>N z=vD_Kr~8i`_G3R1MosqLYcdWh#TiDs!A91^J_>2wG(H01*D#$p2rf3+hzH1aEd9vKl=d4a-l3w%mE;o6Q()ptOZ2Q9cXnj2GYbqnr z<~B7EVPm?*DxWX8p!h}ZL3Kg_Y9B#{2v%0#oE2!x^aNiRwSf^#z#3CJQw_%Tfokw_YMt2_WwAZiLr zuuVT_D!RBLZzYEi`rIn^3Z~Il$FTS;(*iYvL)8M>8wJ?t771JYKX3aPlXu+ zyve$FeR+45o9x%Yg%Gw({>e|4TJ1O(hPIRU4XW$pSaaz_dOidT*qhKY{q?x{@`-Tu zNd>R83vlmJ`?5#PUSwcY z>)GdU#IY_v4!K(Au(w$z(n__dbN~4npO#7gu82^lT{jVTDad0}IxBs2sCb_a56%J$ zIbfaobN#)sr%!RO0>#Xf<5^0i;wHHpopFto&tXNQZN%y{Y7StEA>|lxD#VEa!H21g zMJkC*3;E`$5W*$zDO4tk`LkO_8 zD6Ql;mmcRXNZr5$N*}qX_Qn&ym=O1T;f9oSZy`xMZcS9w8xd74>$yXmymoJ3nLwOK zJ*mufSa%cf>!mJ!FYBx>>BeU?P}YSxEpIgc463R*fi(T4%ZcpoUuehAz@_OeR;N6< z=y%2}Xt<&}I$$8Yh{gj$7`>KMF;?jHh$wubKP?at?bHnd@=j;UI?=Hj$pWWh_HG>a z8gk!n5(LcmM$iysLUN!m01$Y+8;e#1K~*?u_6yconqX)ZVn5E4YbZ;dY}sT%7}S=g zlGuSBm($7~)W;%FDyy7cY zpJmheIg2we560?il9oxxW_V`)L-{H?Cd9vVdV9Ko9J2OP<2|mWj{T*DN~e9e^x6m zuZOBq_;GvL?s)w)y75ov$+qt3v%1<4g+z^hE#5u39I2mXpz5rJ2~Tx@TmvRYK_N#N ziz*a#|B4{i#DeA4^2Ajn)hUuCam+=n9u4-Nbz7q|I(pZ#f z=`(F+jnxzKwG)mQ)|jn&eckZ6j`-ganD?{Y1{wtzF-Hk0jprCS*pWG=u6HC=vx)DA zY3gXIiwqDX`+wNh6)Q}V21uKj5Q;s3)2)&Y*Gh2=wKZ16ryAP$7FFjm{m09eWSZjy zo&hI%ct}R3GKX#6^`E-Utka6N1yxN1C1!=Ag(;f z?TAlXLe&?TCNoF%3orQas)rAdKf^`b%Ywo3ZZrrSFb<4=a*C`z!IBJj*gh#N!!iVf zlFn%r+DL+QNCPrajqae;;rQ|CR5A4yNRm1c1}qh`=!y~A4;4p@g~(UYb{y0l??R?g z$5nav#27M0Fmy{<#pQ7}7ETDcks(nWHz;`*HGX#NXul;!pA7C1H8}i@C-vHCxZ5$2 zk-=9jOe!#tu`D}98U~yEUiQKK`da4TIym`B?nKdR1caOSX2UE_|DO9PFZA3m$tPyv z0sp32vm3_OIvWa;+PJwJDiAeu+!<1qv#rn7-oQ3WYQ`ui&JEmrI}}ny74AfO*RXnnx&zUZv(fu^8q?~Y=E&j_HF>9Og-|3h8L zq_aX35>DMsiCl0eAcv!Kz=@Hp+d_6h_?FreV@_lr9}kKD73;;g+8>V|bNuLFFibTb zUGR8jFFEEHYYW>476r|50Pfn}wB0YRX1K_PcFkLO5hyw-GE%BalCP2px&eN)$!*>E zUUoS-?O03bb#}CIjcW?acy2U1GJNy)sB|Wmj=3FP@V?;@GWN?s?nf)vlUg1GW-*jC z6zi}Xb)IfzK-~&W=kq_JW#;*tON1Up{TDwoa85?8CtO+2;ORYTw*sH~Djj^yvCL{I zl_v&-Ndridpg;g3!xfa$KjHu2lWT%}FU)eS z#LMA4j4#4~m8xE-eaXxX7cf=QCbI_dnwxq*KKRW;Uj=3sr=D=!JXJ0c5Ljnw*R$u+ zwXwDE>jwEQ;J%u#-ns3qilD)_bE4I5Wcd;~S4B3vi_X)*5~o(%vGDY6ZxrF)AHae^L+^v`E)xin3~YDQnkWb_8WeR|FVEvbQS6WlW_c;*TQq+B|Op4%yD`i zWw{!~7AEB*H?Qkxt2kg-#%C$94X*(_Nk-Gx7mHzezHa7%IA&Flw$R*uUje?de?Tv2 zf|-_PVC4nghC-woRMP=R7j1ueaVYtyqMmv7odnYN7K_Z)8H$S~WJI-U?YgDEPtr&S zj>JWqT=lZo)Ym60UQv7DS}#;R$)mDT zWoiK|l3e^NiO+EBYCsNibt^i-aG?Kn3;Sh)N2h0anzYplxQSx?WTI|aPji{MGeyLT z7%zXczLnQIE#@Y~vYCAtf}x>9?Z%CDUR$X+WnutA`++UdzLeXxEuE7Ff2X4!4VAeD zvpf`uK*e<<*y`CDvL*~m2w;Qe5CPZW7AxzxqBB@jbXF>TsREf&MaXoa+V zDA5_=><^vOtByxP@B;Y!wZzy|mMklz#(YWd0arnWVM&IaR^H#Gbg}Zyup320?Gz^0(6SMWgjb@Y z`GUZF_wo&VeNB|JIuP>g6-Z(Rw|R#B^fC9U$2(|-TewzyRv(as+r4DaxY(#H_GPf} zplEYgQicpAG!O8DECWkwf4#h0s7R`u*WH%miy@&;h8fjed7VBft`U%Pd)40?(Wnz+v|eLbvK+e(o(vAuFyz-|>X_28P z2(Lo%2E)S(oe2FDApnB(!e$fmD#5q8IbnV=5H1(ON-3S8xcinmns@d1%F_~N_>V<2 zT0<6E1F8ui(SSB)>$ z7Mq>L)OQ@$^{;B>JVrTw;Zo~ zpU=!buL!y@-MtDs)RppXZBYejNzP51=y@2Rj6q>da2S?y)G-28=otW3BL;5e_`6aXyvX z!$TbV*vdlLwU?>~xMlQW<=J#5TPn}99xF>3uGJuXcI;l|0IJNlue z$ItH6uTz)(#AM0$DAS%tr+Yh)2n1bji4XLtfNMyAZC)7OBVJo~KS^Ppi{4MjG*|=7 zARwqfN(O&mF3*VPQT$lD?IBdcK9EJmOH~1+C{RR@b!%vR;v4(42lcOLxtkAEila+| zTML?hddugrx{-c1OvVUOnREu6C@P*g#rF3mwDI~e*LO9OF4X4hvXIYaxWn4d9ru?W z3OFdttV@SCSMkJ3XXTH9&=yeCLlQr1*U)1;r+X4V+izX3-zGj@@~i6Kn~KpFf$Y-l z3;cCTOx1R_*7&?`1z4(4#U)z%3Fs3}ITo5FrI-?ooARk@2Oy94wJm8XbJ}c)Nid2$ zH31nF9j8`gB7sL|_s}G&u}He%(0&!+`oUI5S@KfaulCyL(p?_LciLFsttS&5;(5Nq zNh9YO&2&;rR5&I95ke3G5yYB=tY=)yUPF?^_j8uI1ea>+Y_}IHSA0R@ks5)3|KS|< z?Q0EgvL97`YP8#adZ?)ezNu;Adp`b%e}E&`=)BDrPx)egvUYD^N~DupHjxr9eqadU zwik!x9zDujHh7hdFy_~4{@b_S4?&0gUv(eAYw6&@S|Pe+bPM_DQyI zy2if${xFMg%gWEdyNCPzRbplqLEpc5=P(G)A{~7;)#sR`LTpB)P3yL#Fyk3YHUr}b z*({mbmY*Lhip8FTQ5;J&J?HRLqR}^aCgcvec|L544zoU)w3_%sE1H^Chefm;W?kg% z#D6{8YH~Vkkw8JvX?Iz5I^z^KH@U$YEi_MXGDB+}Lpgdp}MV@DcR*&8>#?YwF!uZI|jNI4u4t<#*4uZkN;kBN>Nrsj*wRF8F=Gyz({~ z>x2b`IFHa8bVeYg$k@xZYSkObKk$UYAv!TIJ0G^(Nz+J$VXK!A58PJw%y0 zbLd2|M)t>=pvKszWg&tVadBL z*0b`VO+oWNohcpa9~dd;)O_}stVuf83qzc=BLiedl}u&~ilM|$87C(7jH8@_G{Lwa zbS7p^D+Gp#jO3^Dgv=hyS(qNq%g|g+(ZB`Q1F}rY&^8`J?0%z_oTxD1cH7aYi%SPA zDvl-R5l0$19<_EMHaj!7#?e$hSlNDlda5Ai>x*@`4a4FAOv7k6IC1yC*?mmsMk|Ij zPLzw?NYQk(HD#qzUWb}oT8#m{?K^T~w5Hg=k{Q{_9oIJ^WPmlNRyQza=Zu2%a81MP zdymq12(nWYc`}KE6g8zag}=WFPNd~g9h^oY!sIqVMPt&S@XWfJ; z_wn|VT~l_VCYobNQyc8=w!|KOj)&B>soovg))W&-K594!RKJk+1D}#8Gbkz?U*fA(Ln$>rbX5rAHTD&WxfI3lWmi5eY3|IO#1e3tVJ$!mO0yZly|4U{|B#E+c^kcten6s~n%~ zN+iCh2|18nkO?PH%D|plzK=qVOYH(&Fy7O3d0CMeGti98jv5d4*&{pgiHxHc|o*WR#Ax8}b+RhoBVbHA6_kU zcWY{!S8v0PD<8e>(0f~nlCJj=f^(?TNEDR?*>(c(U5kVxI!_d>;|K?(HcPA$h!{^t z)6lY!>z(APoT79IC#ylcsil8+_w2oGzm-{rnQ*jtFoGw2@!U6?3h2{VKjR`cjAZH$4Hxk$O=8^B{hJ)Y7= z@Gcxf!%&;nm1L*C;P86gZXvNAYhCsE1F3}xmv(I2-vHP1GKEJ6ql|h^CnqYpgHAba zN=2dfD!hX%%h8h4jgq5-y&cOmaWrY!{;P$P6UEY==6&NqX7vd&;1fHZa8fp>Qyn?0 z(iDL|c>vjKX9<$(G`02X(g&2w5a zoz$Q_J3Gbcq!zI(BrVf*!1+R^1Rq0I*4{fQ0esuB3@$0waW99i0jv@n0Vs^?Q;jF* zL|*g43Qn0B;Ca27G3mvD1#}C-BFXR4K<%c~_G<2kua0Cj1MgXsDmt#Ti(MNKq?y|h z#9{qrY3xK;tx<CX)Maz3w$|+#3^159zrPK=(aI>SdIzuYpVOMpS2&=XP$Ex-V0+83 zRjp#J^>B3O`a6fe%Y78$(BK|wTw`g(f|V!)9*suX8R{28C#&O1%Okqb5F@|~@@}o_ zKIqUQte9FHj6y*(UU+ixr;ryr?uGg14#_O6ZO0+t<>N$#lmfp)fZ`-x>%qTjkl#%$ zJE)oJlIxJVV=ynPnBwW?CYbaWD%ASTv-1J0_QL6qM|Qq!5rR0xAzlyt#a0=H z7#oisvoznTRq8eRf^Z3lm>X6HN-cM>@__WExXC0x+q11MLYLkUMgB7fO> zCwjHIDZzj0km69xWp?!v>Ly-7R<~v2H}e)whC=^9q3mYnu_}LW_Bb*0tFT5b#p*oA z8P&B?SB)iM~=aEJ@5UVbp`^0tis+(d*zyE*tz?CHOx3kyEcj|IuAuj4E-}RusH}SNze%R^9 z{{Mk16=gqEi2`qh}-W|^}3tyV+^?qD(7 z(%DREwy&^ID&7LZwwst*to&&M?!z~|?%=~r2D;M1W7?=TS1uAQx8kVMzq~mlUP$AxQS`FCOplK`$j6A)I*|C z|2>rWUtJv>-AL^2>gbFDGtu+avZmZHl>vbzp8GIU3Q6%K%2#u<@4Rw+czC?f+3l5v zd}NDY(Hr0kR;vt?^U7}PE)Dr0rP~QdsV+W=fn{9l`jDTHkAg4pjp+0$Fx<@b+m}X_vWZa~F-guK zDmzd7Hr@PpTlry)tIUZ;v&Lrg5ku8HMUGamMt1j&qjUKqoD@+1*={iz=WYGTtN+UM zTBjD|GnrjHQLQ)jB%s&xv1itCb#HYf3*KATzcD`WXMZbg#BV8;h&P;3l2xfJNfVT+ zkw&gs$;oauQ*b3dpJ!qQM46!<{?ne5>`bZT^rdqb-gzNwWLmRo>Y~C=vVp}*J2`JC z+Rw?F@iXwQi)lNY_m$esV+BsvTl>gknP}o}tyD@&d=h>1J3kYbRbl(H+lC z5Fq>U*4xVjZs(Xq^lz$2n(a;unTw5}`p5?-4yngfD;Wa4_4oCs*&R2vxVY$8X7p{7 z22U-I5&KqUH-=A(0+kR!sA__xSV>By61m(9I3IsorV&BmLy`d+iWm|2R~Sz&}E*=A+c1^Z^* z>ThQ$*Z=qNtAXD;_x!)M!kI+Y&67*jN+tP#=7B&;`OlgswBNYMG&_ogbMPRM{JF2g z{v!MEPZwz06~(uIlLJdG(ok@_vU^L5@1#99JyxyW&%JVnrk_h3FJ2~26zf7%wFyu{ zM33(@VK?V%trBNA+cjyDgvGUPA9@l6YHgvsMC2JZlLS)d4`cpP|sjGYH@$SK_@JXJltAfiIAYq-ubfvc4lSZ>%-s7Iz1M(66v zuO>!A@I{B@WGEyP4_edc zrL%LN0_@!E*w4ass7zF-ST3|Ui8=W~!#KLIKm#9lIyVexlWP0q9rbDG$FX3jGg_e- zfHiowMWv6(1M zF|vSE!yNh}f%e>)Jwri~)JttBul&NLigNsPsrj>AfK@muhPIMedh z5};Y7=T;|n=(lS3q-iVrSTooY_4?Cw(sT))wns4LA*KSaKZTE=1!@cMeSN9SXP^wq z{rE)H_zFhnmn&<9n%uF;TOqBewmo<4Il);CUq-Uy6yA+ApCZ@lv`=w?0zG~D41x86 z2pd(%&yY{f*H@Chp`cJ3%di(f7W$eF&?hen{8cFz<6X}9qB3uzUYBm_*27xrDN%tKK!$9cKp}oav*Y${{-Ib-+rlRYG=fja` z_DTxXe;F&T1EwSLpS_kkpcR1aS~^KC?9PifrLe4q7?hsWpML6& zSW*BW558(DOo&vvoI$U+a5_XdX&qXrsxN8?a6K=W`D8K6o>yRr7x*ZWxf-J^RaKJ} zk&uNv^sKjQkzO-}3%%I^hW@B7zL9!RHqK9vAFeeq87nO9VDX=a6%ImwmRG5`; zEOCh$o``W?AOkcfF^Ek}$fY~KNxIVxLjeip_9&Qh-1 zq^H-+&>tC%Z{`mx*4wSE?2BCm{r_ml=dqhCw#+UVPyg6dE?k5?NF7Nv<)yFZxu4Kq z7+WEEPc~iBKc31%{{ugGie7AZ-RCE}@zepkyZi0CQ-pRV<57MM3^kW0sE>PIbw=!x zIoW8+BfqFt+vE?Be-l{_Y&lyubroL@I7o&d1Q)M28ejYexz}-$dUgwHxu`})qFEE; z*X*LCYKxbW)huOU~{o;pU3)HU6@x8`!D9bHXEH_)=vqHE_>GuG8 z5`WD{#K7yn{!6pv${2tN`s>?ck~Efmb0UyyJpJLD(~?e$$CdZhVr&|_dq^RWJ5WqF zxDL&+WhLgMv07&ta*Z&_dy;O&+G0nFNz&K`jL)7GY>LKLm`?+o=7^xYh_h8fh(5c? z1TeW%7< zI%oxA&Xr%zw^RS;t#~~V+n7?JR%`8ZpetN*GTfUb+D&8cRu9yCntkI{{?**QnZZ@h zMz3-m*FW4;4rvRlzEmEM=m{-2k>g#|DQ%sF;mAnbysq-p29e z0miqmMEc{;x3PBt-KhSfPyc2I)?bL@e|j( zVH_$JsCtZ?XHNw5Q-DjP-;Y9YOZxwR`s+J~#Q3ZS;xq__L>&z--QI92Wezg!8fmZP z)#Kd1+O+NH=iwsu)u%Vod^&PEy-iO%qG|aIc^>5v_?SB z;vziV-}j26?XB$PNa$v_@45{loKcz&SQdakb^1Mwsj0p!bd4Dt8BpfDTv)=Nn3jhPLrvSd6_YY6EGIs0nC>mOKP%oTwI((T;zcIj;=0Gx zwIpG@+~_s6J3BK4$SR?W&nzw#LEsi60wcn6&d<+|IIRK{972+LCzj@mqrm23GACnw zX*Wi*TW&n!(Qirm-$pVhgMNkuIEs=Rgo5n{7F(=Zx$S{D;=qFlDd_$2x|i>VO58W1_JmOpQlx5 zgVKhf?<)i`Q4yZ@6OPscQ0iApUU3K$uB_*NvBP34jbl=R^t&K%?d-+GxDm^mg=jDm zmt|&UgsRe!-~pKttv)tRwJAy&VeWxbCyN^-O_E9Y3>+Xh^Ghra1{uB4xS`1Z$~vM?L+1ta+# z9|3F2OKKco zHkUlp(Wj+_*ZXp>PEe*Oj%5&y+V*Py-W{frcB<&sbirU0YvYc=ZZl4dc`;Sv)0^n* z{t7Cv;S#CF;#&EUw*xDg#k$z}*Q4`)_{jHyorK_}X&rLPj$roQ!PK|3$eXk`@U^_I z!+C4a;EHy*r3^u;uAHfH8ipo-)X+A?`PtJ~M%OI``CwCTbBbV@=Vea4ETa@Ij5D*7 zbs5oKwO!v1(#X^!iFpGh^Tj>#WLHJj)iC8rEfk+7uVwE5UlI(~796TboJTfEjd0(p zpR`L`kxyg%gg<)DD4|!IBlHHv25cyJp3mlTr^x$8&S*CU^6Fqv4zom~Di+XZVuFbb zHarX&h)E83ui^M`n80S(4A;Tcu(|G)KQ>_)#c?($N0f1BYf@&{NgPET!wWpu$-3n_ zNmDhF(=;lOyn)pvAIl3j79YrNIiV_9 z^XPcG`?4S}Qx0J*Igt`zb3{$9%9=K8vE)Pyu&Z$^;4!GuaFIy3u3^Sf>aA@8&jsLAV&;ml zmQKqzC9DcY{*;FAHEB$4x(W-VUEdGg{F?|x_g!UG*H~-Vdp6OWF;h9;b^WGrxcf*| z!D`5%F+y;)@VY{l2rO~%j4?@N)JiF(8IcJeu@mUz1_+BG9n8t}-!$n0V*BL@t(sO? zA+Vvj427-og5YdjH1YW1@j(}9HVMVsCFc#{Xf4VxQ<${$(AK&G!?Pl(X&EKOeZLGVurpP|s^XtT6RVA}#V|U1Jo30t>Z$%0DF6I5* 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]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∈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 UMQ*lvlk>j@mv~dN-b!vFvP literal 0 HcmV?d00001 diff --git a/client/public/fonts/JetBrainsMono/JetBrainsMono-Italic[wght].ttf b/client/public/fonts/JetBrainsMono/JetBrainsMono-Italic[wght].ttf new file mode 100644 index 0000000000000000000000000000000000000000..5414835536ecf67b336e82642dbba2c5d2f32dfb GIT binary patch literal 308888 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�z{# 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

p_r)^;F^SB&oAwAUvat>AaH*WM-R9KsutQD*d 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%0D_|qoNNhGUnvG#&**I3n#=L$~UCJ(F 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 Z%h#_b*~^!E-WSq^XhsVQb2wuQ{{qd0QA+>- literal 0 HcmV?d00001 diff --git a/client/scripts/auth_discover_pair.ts b/client/scripts/auth_discover_pair.ts new file mode 100644 index 000000000..aed7c2f21 --- /dev/null +++ b/client/scripts/auth_discover_pair.ts @@ -0,0 +1,341 @@ +/** + * Fleet Onboarding, Discovery, and Pairing Script + * + * End-to-end script that bootstraps a Fleet instance by: + * 1. Authenticating (creating an admin user via REST if needed) + * 2. Resolving a discovery target subnet (from env or the NetworkInfo REST endpoint) + * 3. Running nmap-based device discovery via Connect-RPC streaming + * 4. Pairing newly discovered devices (all or Proto-only, based on env config) + * 5. Reporting the final fleet inventory + * + * Auth and onboarding use the REST API; discovery and pairing use Connect-RPC. + * + * Environment variables: + * FLEET_API_URL – server base URL (default: http://localhost:4000) + * FLEET_ADMIN_USERNAME – admin username (default: admin) + * FLEET_ADMIN_PASSWORD – admin password (default: Pass123!) + * FLEET_SESSION_COOKIE – skip auth and use an existing session cookie + * FLEET_DISCOVERY_TARGET – subnet/IP to scan (default: auto-detected via NetworkInfo) + * FLEET_DISCOVERY_PORTS – comma-separated ports to scan (default: server-advertised ports) + * FLEET_PAIR_ALL_DISCOVERED – "true" to pair all devices, not just Proto rigs + * + * Run with: + * npx tsx client/scripts/auth_discover_pair.ts + */ + +import { create } from "@bufbuild/protobuf"; +import { createClient } from "@connectrpc/connect"; +import { createConnectTransport } from "@connectrpc/connect-web"; +import { DeviceIdentifierListSchema } from "../src/protoFleet/api/generated/common/v1/device_selector_pb"; +import { + FleetManagementService, + ListMinerStateSnapshotsRequestSchema, + PairingStatus, +} from "../src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceSelectorSchema } from "../src/protoFleet/api/generated/minercommand/v1/command_pb"; +import { + PairRequestSchema, + PairingService, + DiscoverRequestSchema, + type Device, +} from "../src/protoFleet/api/generated/pairing/v1/pairing_pb"; + +const baseUrl = process.env.FLEET_API_URL ?? "http://localhost:4000"; +const adminUsername = process.env.FLEET_ADMIN_USERNAME ?? "admin"; +const adminPassword = process.env.FLEET_ADMIN_PASSWORD ?? "Pass123!"; +const requestedSessionCookie = process.env.FLEET_SESSION_COOKIE; +const requestedDiscoveryTarget = process.env.FLEET_DISCOVERY_TARGET; +const requestedDiscoveryPorts = process.env.FLEET_DISCOVERY_PORTS; +const discoveryPorts = requestedDiscoveryPorts + ? requestedDiscoveryPorts + .split(",") + .map((port) => port.trim()) + .filter(Boolean) + : []; +const pairAllDiscovered = process.env.FLEET_PAIR_ALL_DISCOVERED === "true"; + +const transport = createConnectTransport({ baseUrl }); +const pairingClient = createClient(PairingService, transport); +const fleetClient = createClient(FleetManagementService, transport); + +type FleetInitStatusResponse = { + status?: { + adminCreated?: boolean; + }; +}; + +type NetworkInfoResponse = { + networkInfo?: { + subnet?: string; + localIp?: string; + gateway?: string; + }; +}; + +type AuthenticateResponse = { + userInfo?: { + username?: string; + }; + sessionExpiry?: string | number; +}; + +async function postJson( + path: string, + body: unknown, + sessionCookie?: string, +): Promise<{ data: T; setCookie: string | null }> { + const headers = new Headers({ + "Content-Type": "application/json", + }); + + if (sessionCookie) { + headers.set("Cookie", sessionCookie); + } + + const response = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const responseText = await response.text(); + if (!response.ok) { + throw new Error(`${path} failed with ${response.status}: ${responseText}`); + } + + return { + data: responseText ? (JSON.parse(responseText) as T) : ({} as T), + setCookie: response.headers.get("set-cookie"), + }; +} + +function formatSessionCookie(rawCookie: string): string { + if (rawCookie.includes("=")) { + return rawCookie; + } + + return `fleet_session=${rawCookie}`; +} + +function normalizeSubnetCIDR(value: string): string { + const [ip, prefixString] = value.split("/"); + if (!ip || !prefixString) { + return value; + } + + const octets = ip.split(".").map((part) => Number(part)); + const prefix = Number(prefixString); + const validIPv4 = + octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255); + if (!validIPv4 || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + return value; + } + + const ipInt = octets.reduce((acc, octet) => (acc << 8) | octet, 0) >>> 0; + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + const network = ipInt & mask; + + const normalizedOctets = [(network >>> 24) & 0xff, (network >>> 16) & 0xff, (network >>> 8) & 0xff, network & 0xff]; + + return `${normalizedOctets.join(".")}/${prefix}`; +} + +// Step 1: Authenticate — reuse an existing cookie, or onboard + login via REST +async function ensureSessionCookie(): Promise { + if (requestedSessionCookie) { + const cookie = formatSessionCookie(requestedSessionCookie); + console.log(`Using existing Fleet session cookie for ${baseUrl}`); + return cookie; + } + + const initStatus = await postJson("/onboarding.v1.OnboardingService/GetFleetInitStatus", {}); + + if (!initStatus.data.status?.adminCreated) { + console.log(`Creating Fleet admin user ${adminUsername}...`); + await postJson("/onboarding.v1.OnboardingService/CreateAdminLogin", { + username: adminUsername, + password: adminPassword, + }); + } else { + console.log(`Fleet already onboarded. Authenticating as ${adminUsername}...`); + } + + const authResponse = await postJson("/auth.v1.AuthService/Authenticate", { + username: adminUsername, + password: adminPassword, + }); + + if (!authResponse.setCookie) { + throw new Error("Authenticate succeeded but no session cookie was returned."); + } + + const sessionCookie = authResponse.setCookie.split(";")[0]; + const username = authResponse.data.userInfo?.username ?? adminUsername; + console.log(`Authenticated as ${username}. Session cookie captured.`); + return sessionCookie; +} + +// Step 2: Resolve subnet — use the env override or query the NetworkInfo REST endpoint +async function getDiscoveryTarget(sessionCookie: string): Promise { + if (requestedDiscoveryTarget) { + return requestedDiscoveryTarget; + } + + const response = await postJson( + "/networkinfo.v1.NetworkInfoService/GetNetworkInfo", + {}, + sessionCookie, + ); + + const subnet = response.data.networkInfo?.subnet; + if (!subnet) { + throw new Error("Network info response did not include a subnet."); + } + + const normalizedSubnet = normalizeSubnetCIDR(subnet); + console.log( + `Using discovery target ${normalizedSubnet} (backend reported subnet ${subnet}, local IP ${response.data.networkInfo?.localIp ?? "unknown"}).`, + ); + return normalizedSubnet; +} + +// Step 3: Discover devices via nmap Connect-RPC streaming +async function discoverDevices(sessionCookie: string, target: string): Promise { + const request = create(DiscoverRequestSchema, { + mode: { + case: "nmap", + value: + discoveryPorts.length > 0 + ? { + target, + ports: discoveryPorts, + } + : { + target, + }, + }, + }); + + const discovered = new Map(); + + for await (const response of pairingClient.discover(request, { + headers: { + Cookie: sessionCookie, + }, + })) { + if (response.error) { + console.warn(`Discovery warning: ${response.error}`); + } + + for (const device of response.devices) { + discovered.set(device.deviceIdentifier, device); + } + } + + return [...discovered.values()]; +} + +// Step 4: Pair selected devices via Connect-RPC +async function pairDevices(sessionCookie: string, devices: Device[]): Promise { + if (devices.length === 0) { + return []; + } + + const request = create(PairRequestSchema, { + deviceSelector: create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers: devices.map((device) => device.deviceIdentifier), + }), + }, + }), + }); + + const response = await pairingClient.pair(request, { + headers: { + Cookie: sessionCookie, + }, + }); + + return response.failedDeviceIds; +} + +// Step 5: List current fleet inventory via Connect-RPC +async function listMiners(sessionCookie: string) { + const response = await fleetClient.listMinerStateSnapshots( + create(ListMinerStateSnapshotsRequestSchema, { + pageSize: 100, + }), + { + headers: { + Cookie: sessionCookie, + }, + }, + ); + + return response.miners; +} + +function summarizeDevices(label: string, devices: Device[]) { + const counts = new Map(); + + for (const device of devices) { + const key = `${device.driverName}:${device.manufacturer} ${device.model}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + console.log(`${label}: ${devices.length}`); + for (const [key, count] of [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + console.log(` ${count} x ${key}`); + } +} + +async function main() { + console.log(`Bootstrapping Fleet fake-miner setup against ${baseUrl}`); + + const sessionCookie = await ensureSessionCookie(); + const discoveryTarget = await getDiscoveryTarget(sessionCookie); + + console.log(`Scanning ${discoveryTarget} on ports ${discoveryPorts.join(", ")}...`); + const discoveredDevices = await discoverDevices(sessionCookie, discoveryTarget); + summarizeDevices("Discovered devices", discoveredDevices); + + const currentMiners = await listMiners(sessionCookie); + const alreadyPairedIds = new Set( + currentMiners + .filter((miner) => miner.pairingStatus === PairingStatus.PAIRED) + .map((miner) => miner.deviceIdentifier), + ); + + const candidateDevices = pairAllDiscovered + ? discoveredDevices + : discoveredDevices.filter((device) => device.driverName === "proto"); + const devicesToPair = candidateDevices.filter((device) => !alreadyPairedIds.has(device.deviceIdentifier)); + + summarizeDevices(pairAllDiscovered ? "Pairing all discovered devices" : "Pairing Proto devices", devicesToPair); + + if (devicesToPair.length === 0) { + console.log("No newly discovered devices need pairing."); + console.log( + `Fleet now has ${currentMiners.length} miner snapshot(s), including ${currentMiners.filter((miner) => miner.driverName === "proto").length} Proto rig(s).`, + ); + return; + } + + const failedDeviceIds = await pairDevices(sessionCookie, devicesToPair); + if (failedDeviceIds.length > 0) { + console.warn(`Pairing failed for ${failedDeviceIds.length} device(s): ${failedDeviceIds.join(", ")}`); + } else { + console.log("Pairing completed without failures."); + } + + const miners = await listMiners(sessionCookie); + const pairedProtoMiners = miners.filter((miner) => miner.driverName === "proto"); + console.log(`Fleet now has ${miners.length} miner snapshot(s), including ${pairedProtoMiners.length} Proto rig(s).`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/client/scripts/dev-protoOS.ts b/client/scripts/dev-protoOS.ts new file mode 100644 index 000000000..59d605e52 --- /dev/null +++ b/client/scripts/dev-protoOS.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import { spawn } from "child_process"; +import { config } from "dotenv"; +import { fileURLToPath } from "url"; +import { dirname, resolve } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Load .env file +config({ path: resolve(__dirname, "../.env") }); + +console.log("Starting ProtoOS..."); + +// Start vite normally +const vite = spawn("vite", ["--mode", "protoOS"], { + stdio: "inherit", + shell: true, +}); + +vite.on("exit", (code) => { + process.exit(code || 0); +}); diff --git a/client/scripts/generate_api_ts.mjs b/client/scripts/generate_api_ts.mjs new file mode 100644 index 000000000..8d3f9d57a --- /dev/null +++ b/client/scripts/generate_api_ts.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import { generateApi } from "swagger-typescript-api"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file +const __dirname = path.dirname(__filename); // get the name of the directory + +const swaggerSchemaPath = path.resolve( + __dirname, + "../../proto-rig-api/openapi/MDK-API.json", +); + +if (!fs.existsSync(swaggerSchemaPath)) { + console.error(`\nCould not find Swagger Schema file: ${swaggerSchemaPath}\n`); + process.exitCode = 1; +} + +const [fileName = "generatedApi.ts"] = process.argv.slice(2); +const fileDir = path.resolve(__dirname, "../src/protoOS/api"); + +generateApi({ + fileName, + input: swaggerSchemaPath, + extractRequestParams: true, + output: fileDir, + addReadonly: true, + httpClientType: "fetch", + sortTypes: true, +}).then(() => { + const filePath = path.join(fileDir, fileName); + let fileContent = fs.readFileSync(filePath, "utf-8"); + + fileContent = fileContent.replace( + /public baseUrl: string = ".*";/g, + 'public baseUrl: string = "";', + ); + + fs.writeFileSync(filePath, fileContent, "utf-8"); +}); diff --git a/client/src/protoFleet/api/ScheduleApiContext.ts b/client/src/protoFleet/api/ScheduleApiContext.ts new file mode 100644 index 000000000..b820f5806 --- /dev/null +++ b/client/src/protoFleet/api/ScheduleApiContext.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +import type { UseScheduleApiResult } from "@/protoFleet/api/useScheduleApi"; + +export const ScheduleApiContext = createContext(null); + +export const useScheduleApiContext = () => { + const scheduleApi = useContext(ScheduleApiContext); + + if (scheduleApi === null) { + throw new Error("useScheduleApiContext must be used within a ScheduleApiProvider"); + } + + return scheduleApi; +}; diff --git a/client/src/protoFleet/api/ScheduleApiProvider.tsx b/client/src/protoFleet/api/ScheduleApiProvider.tsx new file mode 100644 index 000000000..c768c1c95 --- /dev/null +++ b/client/src/protoFleet/api/ScheduleApiProvider.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +import { ScheduleApiContext } from "@/protoFleet/api/ScheduleApiContext"; +import useScheduleApi from "@/protoFleet/api/useScheduleApi"; + +export const ScheduleApiProvider = ({ children }: { children: ReactNode }) => { + const scheduleApi = useScheduleApi(); + + return {children}; +}; diff --git a/client/src/protoFleet/api/clients.ts b/client/src/protoFleet/api/clients.ts new file mode 100644 index 000000000..1fd3dce71 --- /dev/null +++ b/client/src/protoFleet/api/clients.ts @@ -0,0 +1,48 @@ +import { createClient } from "@connectrpc/connect"; +import { transport } from "./transport"; +import { ActivityService } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { ApiKeyService } from "@/protoFleet/api/generated/apikey/v1/apikey_pb"; +import { AuthService } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { DeviceSetService } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { ErrorQueryService } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { FleetManagementService } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { ForemanImportService } from "@/protoFleet/api/generated/foremanimport/v1/foremanimport_pb"; +import { MinerCommandService } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { NetworkInfoService } from "@/protoFleet/api/generated/networkinfo/v1/networkinfo_pb"; +import { OnboardingService } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +import { PairingService } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import { PoolsService } from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import { ScheduleService } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import { TelemetryService } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +const activityClient = createClient(ActivityService, transport); +const apiKeyClient = createClient(ApiKeyService, transport); +const authClient = createClient(AuthService, transport); +const errorQueryClient = createClient(ErrorQueryService, transport); +const networkInfoClient = createClient(NetworkInfoService, transport); +const pairingClient = createClient(PairingService, transport); +const fleetManagementClient = createClient(FleetManagementService, transport); +const onboardingClient = createClient(OnboardingService, transport); +const minerCommandClient = createClient(MinerCommandService, transport); +const poolsClient = createClient(PoolsService, transport); +const scheduleClient = createClient(ScheduleService, transport); +const deviceSetClient = createClient(DeviceSetService, transport); +const telemetryClient = createClient(TelemetryService, transport); +const foremanImportClient = createClient(ForemanImportService, transport); + +export { + activityClient, + apiKeyClient, + authClient, + deviceSetClient, + errorQueryClient, + networkInfoClient, + pairingClient, + fleetManagementClient, + onboardingClient, + minerCommandClient, + poolsClient, + scheduleClient, + telemetryClient, + foremanImportClient, +}; diff --git a/client/src/protoFleet/api/constants.ts b/client/src/protoFleet/api/constants.ts new file mode 100644 index 000000000..cb6563c79 --- /dev/null +++ b/client/src/protoFleet/api/constants.ts @@ -0,0 +1 @@ +export const API_PROXY_BASE = "/api-proxy"; diff --git a/client/src/protoFleet/api/fetchAllMinerSnapshots.test.ts b/client/src/protoFleet/api/fetchAllMinerSnapshots.test.ts new file mode 100644 index 000000000..d776987c5 --- /dev/null +++ b/client/src/protoFleet/api/fetchAllMinerSnapshots.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchAllMinerSnapshots } from "./fetchAllMinerSnapshots"; + +const mockListMinerStateSnapshots = vi.fn(); + +vi.mock("@/protoFleet/api/clients", () => ({ + fleetManagementClient: { + listMinerStateSnapshots: (...args: unknown[]) => mockListMinerStateSnapshots(...args), + }, +})); + +function minerSnapshot(deviceIdentifier: string) { + return { deviceIdentifier } as { deviceIdentifier: string }; +} + +describe("fetchAllMinerSnapshots", () => { + beforeEach(() => { + mockListMinerStateSnapshots.mockReset(); + }); + + it("returns a map from a single page", async () => { + mockListMinerStateSnapshots.mockResolvedValueOnce({ + miners: [minerSnapshot("d1"), minerSnapshot("d2")], + cursor: "", + }); + + const result = await fetchAllMinerSnapshots({ groupIds: [1n] }); + + expect(result).toEqual({ d1: minerSnapshot("d1"), d2: minerSnapshot("d2") }); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(1); + expect(mockListMinerStateSnapshots).toHaveBeenCalledWith( + { pageSize: 1000, cursor: "", filter: { groupIds: [1n] } }, + { signal: undefined }, + ); + }); + + it("accumulates results across multiple pages", async () => { + mockListMinerStateSnapshots + .mockResolvedValueOnce({ + miners: [minerSnapshot("d1"), minerSnapshot("d2")], + cursor: "page2", + }) + .mockResolvedValueOnce({ + miners: [minerSnapshot("d3")], + cursor: "", + }); + + const result = await fetchAllMinerSnapshots({ rackIds: [5n] }); + + expect(result).toEqual({ + d1: minerSnapshot("d1"), + d2: minerSnapshot("d2"), + d3: minerSnapshot("d3"), + }); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(2); + expect(mockListMinerStateSnapshots).toHaveBeenNthCalledWith( + 2, + { pageSize: 1000, cursor: "page2", filter: { rackIds: [5n] } }, + expect.anything(), + ); + }); + + it("throws AbortError when signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(fetchAllMinerSnapshots({}, controller.signal)).rejects.toThrow( + expect.objectContaining({ name: "AbortError" }), + ); + expect(mockListMinerStateSnapshots).not.toHaveBeenCalled(); + }); + + it("throws when signal is aborted between pages", async () => { + const controller = new AbortController(); + + mockListMinerStateSnapshots.mockImplementationOnce(async () => { + controller.abort(); + return { miners: [minerSnapshot("d1")], cursor: "page2" }; + }); + + await expect(fetchAllMinerSnapshots({}, controller.signal)).rejects.toThrow( + expect.objectContaining({ name: "AbortError" }), + ); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(1); + }); + + it("propagates RPC errors without returning partial data", async () => { + const rpcError = new Error("server unavailable"); + + mockListMinerStateSnapshots + .mockResolvedValueOnce({ + miners: [minerSnapshot("d1")], + cursor: "page2", + }) + .mockRejectedValueOnce(rpcError); + + await expect(fetchAllMinerSnapshots({})).rejects.toThrow("server unavailable"); + expect(mockListMinerStateSnapshots).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/protoFleet/api/fetchAllMinerSnapshots.ts b/client/src/protoFleet/api/fetchAllMinerSnapshots.ts new file mode 100644 index 000000000..0574d9ce5 --- /dev/null +++ b/client/src/protoFleet/api/fetchAllMinerSnapshots.ts @@ -0,0 +1,42 @@ +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import type { + MinerListFilter, + MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type MinerListFilterInit = Omit, "$typeName" | "$unknown">; + +/** + * Paginate through all pages of `ListMinerStateSnapshots` and return a + * map of `deviceIdentifier → MinerStateSnapshot`. + * + * The server caps `page_size` at 1000, so device sets with more members + * require multiple round-trips. Results are accumulated locally and + * returned only after every page succeeds — callers never see partial data. + */ +export async function fetchAllMinerSnapshots( + filter: MinerListFilterInit, + signal?: AbortSignal, +): Promise> { + const map: Record = {}; + let cursor = ""; + + do { + if (signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + + const response = await fleetManagementClient.listMinerStateSnapshots( + { pageSize: 1000, cursor, filter }, + { signal }, + ); + + for (const miner of response.miners) { + map[miner.deviceIdentifier] = miner; + } + + cursor = response.cursor; + } while (cursor); + + return map; +} diff --git a/client/src/protoFleet/api/generated/activity/v1/activity_pb.ts b/client/src/protoFleet/api/generated/activity/v1/activity_pb.ts new file mode 100644 index 000000000..29da137e5 --- /dev/null +++ b/client/src/protoFleet/api/generated/activity/v1/activity_pb.ts @@ -0,0 +1,455 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file activity/v1/activity.proto (package activity.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_struct, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { JsonObject, Message } from "@bufbuild/protobuf"; + +/** + * Describes the file activity/v1/activity.proto. + */ +export const file_activity_v1_activity: GenFile = + /*@__PURE__*/ + fileDesc( + "ChphY3Rpdml0eS92MS9hY3Rpdml0eS5wcm90bxILYWN0aXZpdHkudjEizgMKDUFjdGl2aXR5RW50cnkSEAoIZXZlbnRfaWQYASABKAkSFgoOZXZlbnRfY2F0ZWdvcnkYAiABKAkSEgoKZXZlbnRfdHlwZRgDIAEoCRITCgtkZXNjcmlwdGlvbhgEIAEoCRIXCgpzY29wZV90eXBlGAUgASgJSACIAQESGAoLc2NvcGVfbGFiZWwYBiABKAlIAYgBARITCgtzY29wZV9jb3VudBgHIAEoBRISCgphY3Rvcl90eXBlGAggASgJEhQKB3VzZXJfaWQYCSABKAlIAogBARIVCgh1c2VybmFtZRgKIAEoCUgDiAEBEi4KCmNyZWF0ZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCG1ldGFkYXRhGAwgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEgEiAEBEg4KBnJlc3VsdBgNIAEoCRIaCg1lcnJvcl9tZXNzYWdlGA4gASgJSAWIAQFCDQoLX3Njb3BlX3R5cGVCDgoMX3Njb3BlX2xhYmVsQgoKCF91c2VyX2lkQgsKCV91c2VybmFtZUILCglfbWV0YWRhdGFCEAoOX2Vycm9yX21lc3NhZ2Ui2QEKDkFjdGl2aXR5RmlsdGVyEhgKEGV2ZW50X2NhdGVnb3JpZXMYASADKAkSEwoLZXZlbnRfdHlwZXMYAiADKAkSEAoIdXNlcl9pZHMYAyADKAkSLgoKc3RhcnRfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLAoIZW5kX3RpbWUYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhMKC3NlYXJjaF90ZXh0GAYgASgJEhMKC3Njb3BlX3R5cGVzGAcgAygJInYKFUxpc3RBY3Rpdml0aWVzUmVxdWVzdBIrCgZmaWx0ZXIYASABKAsyGy5hY3Rpdml0eS52MS5BY3Rpdml0eUZpbHRlchIcCglwYWdlX3NpemUYAiABKAVCCbpIBhoEGGQoARISCgpwYWdlX3Rva2VuGAMgASgJInYKFkxpc3RBY3Rpdml0aWVzUmVzcG9uc2USLgoKYWN0aXZpdGllcxgBIAMoCzIaLmFjdGl2aXR5LnYxLkFjdGl2aXR5RW50cnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhMKC3RvdGFsX2NvdW50GAMgASgFIkYKF0V4cG9ydEFjdGl2aXRpZXNSZXF1ZXN0EisKBmZpbHRlchgBIAEoCzIbLmFjdGl2aXR5LnYxLkFjdGl2aXR5RmlsdGVyIikKGEV4cG9ydEFjdGl2aXRpZXNSZXNwb25zZRINCgVjaHVuaxgBIAEoDCIiCiBMaXN0QWN0aXZpdHlGaWx0ZXJPcHRpb25zUmVxdWVzdCKTAQohTGlzdEFjdGl2aXR5RmlsdGVyT3B0aW9uc1Jlc3BvbnNlEjEKC2V2ZW50X3R5cGVzGAEgAygLMhwuYWN0aXZpdHkudjEuRXZlbnRUeXBlT3B0aW9uEhMKC3Njb3BlX3R5cGVzGAIgAygJEiYKBXVzZXJzGAMgAygLMhcuYWN0aXZpdHkudjEuVXNlck9wdGlvbiI9Cg9FdmVudFR5cGVPcHRpb24SEgoKZXZlbnRfdHlwZRgBIAEoCRIWCg5ldmVudF9jYXRlZ29yeRgCIAEoCSIvCgpVc2VyT3B0aW9uEg8KB3VzZXJfaWQYASABKAkSEAoIdXNlcm5hbWUYAiABKAkyywIKD0FjdGl2aXR5U2VydmljZRJZCg5MaXN0QWN0aXZpdGllcxIiLmFjdGl2aXR5LnYxLkxpc3RBY3Rpdml0aWVzUmVxdWVzdBojLmFjdGl2aXR5LnYxLkxpc3RBY3Rpdml0aWVzUmVzcG9uc2USYQoQRXhwb3J0QWN0aXZpdGllcxIkLmFjdGl2aXR5LnYxLkV4cG9ydEFjdGl2aXRpZXNSZXF1ZXN0GiUuYWN0aXZpdHkudjEuRXhwb3J0QWN0aXZpdGllc1Jlc3BvbnNlMAESegoZTGlzdEFjdGl2aXR5RmlsdGVyT3B0aW9ucxItLmFjdGl2aXR5LnYxLkxpc3RBY3Rpdml0eUZpbHRlck9wdGlvbnNSZXF1ZXN0Gi4uYWN0aXZpdHkudjEuTGlzdEFjdGl2aXR5RmlsdGVyT3B0aW9uc1Jlc3BvbnNlQrgBCg9jb20uYWN0aXZpdHkudjFCDUFjdGl2aXR5UHJvdG9QAVpJZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvYWN0aXZpdHkvdjE7YWN0aXZpdHl2MaICA0FYWKoCC0FjdGl2aXR5LlYxygILQWN0aXZpdHlcVjHiAhdBY3Rpdml0eVxWMVxHUEJNZXRhZGF0YeoCDEFjdGl2aXR5OjpWMWIGcHJvdG8z", + [file_google_protobuf_timestamp, file_google_protobuf_struct, file_buf_validate_validate], + ); + +/** + * A single recorded activity event + * + * @generated from message activity.v1.ActivityEntry + */ +export type ActivityEntry = Message<"activity.v1.ActivityEntry"> & { + /** + * Unique identifier for this activity event + * + * @generated from field: string event_id = 1; + */ + eventId: string; + + /** + * High-level category (e.g. "auth", "device_command", "fleet_management") + * + * @generated from field: string event_category = 2; + */ + eventCategory: string; + + /** + * Specific action type (e.g. "login", "reboot", "delete_miners") + * + * @generated from field: string event_type = 3; + */ + eventType: string; + + /** + * Human-readable description of the activity + * + * @generated from field: string description = 4; + */ + description: string; + + /** + * Type of scope affected (e.g. "group", "rack"), if applicable + * + * @generated from field: optional string scope_type = 5; + */ + scopeType?: string; + + /** + * Label of the scope (e.g. group/rack name), if applicable + * + * @generated from field: optional string scope_label = 6; + */ + scopeLabel?: string; + + /** + * Number of devices affected by the activity + * + * @generated from field: int32 scope_count = 7; + */ + scopeCount: number; + + /** + * Type of actor that performed the activity ("user", "system", or "scheduler") + * + * @generated from field: string actor_type = 8; + */ + actorType: string; + + /** + * External user ID of the actor, if performed by a user + * + * @generated from field: optional string user_id = 9; + */ + userId?: string; + + /** + * Username of the actor at the time of the activity, if performed by a user + * + * @generated from field: optional string username = 10; + */ + username?: string; + + /** + * Timestamp when the activity was recorded + * + * @generated from field: google.protobuf.Timestamp created_at = 11; + */ + createdAt?: Timestamp; + + /** + * Additional structured metadata associated with the activity + * + * @generated from field: optional google.protobuf.Struct metadata = 12; + */ + metadata?: JsonObject; + + /** + * Outcome of the activity ("success" or "failure") + * + * @generated from field: string result = 13; + */ + result: string; + + /** + * Error message for failed activities, if any + * + * @generated from field: optional string error_message = 14; + */ + errorMessage?: string; +}; + +/** + * Describes the message activity.v1.ActivityEntry. + * Use `create(ActivityEntrySchema)` to create a new message. + */ +export const ActivityEntrySchema: GenMessage = /*@__PURE__*/ messageDesc(file_activity_v1_activity, 0); + +/** + * Criteria for filtering activity entries + * + * @generated from message activity.v1.ActivityFilter + */ +export type ActivityFilter = Message<"activity.v1.ActivityFilter"> & { + /** + * Filter by event categories (empty = all categories) + * + * @generated from field: repeated string event_categories = 1; + */ + eventCategories: string[]; + + /** + * Filter by specific event types (empty = all types) + * + * @generated from field: repeated string event_types = 2; + */ + eventTypes: string[]; + + /** + * Filter by user IDs (empty = all users) + * + * @generated from field: repeated string user_ids = 3; + */ + userIds: string[]; + + /** + * Include activities at or after this time + * + * @generated from field: google.protobuf.Timestamp start_time = 4; + */ + startTime?: Timestamp; + + /** + * Include activities at or before this time + * + * @generated from field: google.protobuf.Timestamp end_time = 5; + */ + endTime?: Timestamp; + + /** + * Case-insensitive text search on activity descriptions + * + * @generated from field: string search_text = 6; + */ + searchText: string; + + /** + * Filter by scope types (empty = all scope types) + * + * @generated from field: repeated string scope_types = 7; + */ + scopeTypes: string[]; +}; + +/** + * Describes the message activity.v1.ActivityFilter. + * Use `create(ActivityFilterSchema)` to create a new message. + */ +export const ActivityFilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_activity_v1_activity, 1); + +/** + * Request for listing activities with pagination + * + * @generated from message activity.v1.ListActivitiesRequest + */ +export type ListActivitiesRequest = Message<"activity.v1.ListActivitiesRequest"> & { + /** + * Filter criteria for selecting activities + * + * @generated from field: activity.v1.ActivityFilter filter = 1; + */ + filter?: ActivityFilter; + + /** + * Maximum number of activities to return per page + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Opaque token for fetching the next page of results + * + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message activity.v1.ListActivitiesRequest. + * Use `create(ListActivitiesRequestSchema)` to create a new message. + */ +export const ListActivitiesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 2); + +/** + * Response containing a page of activities + * + * @generated from message activity.v1.ListActivitiesResponse + */ +export type ListActivitiesResponse = Message<"activity.v1.ListActivitiesResponse"> & { + /** + * Activity entries in the current page + * + * @generated from field: repeated activity.v1.ActivityEntry activities = 1; + */ + activities: ActivityEntry[]; + + /** + * Token for retrieving the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Total number of activities matching the filter (returned on first page only) + * + * @generated from field: int32 total_count = 3; + */ + totalCount: number; +}; + +/** + * Describes the message activity.v1.ListActivitiesResponse. + * Use `create(ListActivitiesResponseSchema)` to create a new message. + */ +export const ListActivitiesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 3); + +/** + * Request for exporting activities + * + * @generated from message activity.v1.ExportActivitiesRequest + */ +export type ExportActivitiesRequest = Message<"activity.v1.ExportActivitiesRequest"> & { + /** + * Filter criteria for selecting activities to export + * + * @generated from field: activity.v1.ActivityFilter filter = 1; + */ + filter?: ActivityFilter; +}; + +/** + * Describes the message activity.v1.ExportActivitiesRequest. + * Use `create(ExportActivitiesRequestSchema)` to create a new message. + */ +export const ExportActivitiesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 4); + +/** + * Streamed chunk of exported activity data + * + * @generated from message activity.v1.ExportActivitiesResponse + */ +export type ExportActivitiesResponse = Message<"activity.v1.ExportActivitiesResponse"> & { + /** + * Raw bytes for a portion of the exported data + * + * @generated from field: bytes chunk = 1; + */ + chunk: Uint8Array; +}; + +/** + * Describes the message activity.v1.ExportActivitiesResponse. + * Use `create(ExportActivitiesResponseSchema)` to create a new message. + */ +export const ExportActivitiesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 5); + +/** + * Request for fetching available filter options (org is derived from session) + * + * @generated from message activity.v1.ListActivityFilterOptionsRequest + */ +export type ListActivityFilterOptionsRequest = Message<"activity.v1.ListActivityFilterOptionsRequest"> & {}; + +/** + * Describes the message activity.v1.ListActivityFilterOptionsRequest. + * Use `create(ListActivityFilterOptionsRequestSchema)` to create a new message. + */ +export const ListActivityFilterOptionsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 6); + +/** + * Available options for building activity filters + * + * @generated from message activity.v1.ListActivityFilterOptionsResponse + */ +export type ListActivityFilterOptionsResponse = Message<"activity.v1.ListActivityFilterOptionsResponse"> & { + /** + * Available event type options + * + * @generated from field: repeated activity.v1.EventTypeOption event_types = 1; + */ + eventTypes: EventTypeOption[]; + + /** + * Available scope types + * + * @generated from field: repeated string scope_types = 2; + */ + scopeTypes: string[]; + + /** + * Available users + * + * @generated from field: repeated activity.v1.UserOption users = 3; + */ + users: UserOption[]; +}; + +/** + * Describes the message activity.v1.ListActivityFilterOptionsResponse. + * Use `create(ListActivityFilterOptionsResponseSchema)` to create a new message. + */ +export const ListActivityFilterOptionsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 7); + +/** + * An event type and its associated category + * + * @generated from message activity.v1.EventTypeOption + */ +export type EventTypeOption = Message<"activity.v1.EventTypeOption"> & { + /** + * Event type value + * + * @generated from field: string event_type = 1; + */ + eventType: string; + + /** + * Category this event type belongs to + * + * @generated from field: string event_category = 2; + */ + eventCategory: string; +}; + +/** + * Describes the message activity.v1.EventTypeOption. + * Use `create(EventTypeOptionSchema)` to create a new message. + */ +export const EventTypeOptionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_activity_v1_activity, 8); + +/** + * A user that can be referenced in activity filters + * + * @generated from message activity.v1.UserOption + */ +export type UserOption = Message<"activity.v1.UserOption"> & { + /** + * External user identifier + * + * @generated from field: string user_id = 1; + */ + userId: string; + + /** + * Username + * + * @generated from field: string username = 2; + */ + username: string; +}; + +/** + * Describes the message activity.v1.UserOption. + * Use `create(UserOptionSchema)` to create a new message. + */ +export const UserOptionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_activity_v1_activity, 9); + +/** + * ActivityService provides APIs for querying and exporting activity events + * and retrieving available filter options + * + * @generated from service activity.v1.ActivityService + */ +export const ActivityService: GenService<{ + /** + * Returns a paginated list of activity entries matching the supplied filter + * + * @generated from rpc activity.v1.ActivityService.ListActivities + */ + listActivities: { + methodKind: "unary"; + input: typeof ListActivitiesRequestSchema; + output: typeof ListActivitiesResponseSchema; + }; + /** + * Streams activity entries as serialized export data (CSV) + * + * @generated from rpc activity.v1.ActivityService.ExportActivities + */ + exportActivities: { + methodKind: "server_streaming"; + input: typeof ExportActivitiesRequestSchema; + output: typeof ExportActivitiesResponseSchema; + }; + /** + * Returns available values for activity filters (event types, scope types, users) + * + * @generated from rpc activity.v1.ActivityService.ListActivityFilterOptions + */ + listActivityFilterOptions: { + methodKind: "unary"; + input: typeof ListActivityFilterOptionsRequestSchema; + output: typeof ListActivityFilterOptionsResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_activity_v1_activity, 0); diff --git a/client/src/protoFleet/api/generated/apikey/v1/apikey_pb.ts b/client/src/protoFleet/api/generated/apikey/v1/apikey_pb.ts new file mode 100644 index 000000000..56dd8f894 --- /dev/null +++ b/client/src/protoFleet/api/generated/apikey/v1/apikey_pb.ts @@ -0,0 +1,237 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file apikey/v1/apikey.proto (package apikey.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file apikey/v1/apikey.proto. + */ +export const file_apikey_v1_apikey: GenFile = + /*@__PURE__*/ + fileDesc( + "ChZhcGlrZXkvdjEvYXBpa2V5LnByb3RvEglhcGlrZXkudjEiXwoTQ3JlYXRlQXBpS2V5UmVxdWVzdBIYCgRuYW1lGAEgASgJQgq6SAdyBRABGP8BEi4KCmV4cGlyZXNfYXQYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIkwKFENyZWF0ZUFwaUtleVJlc3BvbnNlEg8KB2FwaV9rZXkYASABKAkSIwoEaW5mbxgCIAEoCzIVLmFwaWtleS52MS5BcGlLZXlJbmZvIhQKEkxpc3RBcGlLZXlzUmVxdWVzdCI+ChNMaXN0QXBpS2V5c1Jlc3BvbnNlEicKCGFwaV9rZXlzGAEgAygLMhUuYXBpa2V5LnYxLkFwaUtleUluZm8i4AEKCkFwaUtleUluZm8SDgoGa2V5X2lkGAEgASgJEgwKBG5hbWUYAiABKAkSDgoGcHJlZml4GAMgASgJEi4KCmNyZWF0ZWRfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCmV4cGlyZXNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDGxhc3RfdXNlZF9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKY3JlYXRlZF9ieRgHIAEoCSIuChNSZXZva2VBcGlLZXlSZXF1ZXN0EhcKBmtleV9pZBgBIAEoCUIHukgEcgIQASIWChRSZXZva2VBcGlLZXlSZXNwb25zZTL/AQoNQXBpS2V5U2VydmljZRJPCgxDcmVhdGVBcGlLZXkSHi5hcGlrZXkudjEuQ3JlYXRlQXBpS2V5UmVxdWVzdBofLmFwaWtleS52MS5DcmVhdGVBcGlLZXlSZXNwb25zZRJMCgtMaXN0QXBpS2V5cxIdLmFwaWtleS52MS5MaXN0QXBpS2V5c1JlcXVlc3QaHi5hcGlrZXkudjEuTGlzdEFwaUtleXNSZXNwb25zZRJPCgxSZXZva2VBcGlLZXkSHi5hcGlrZXkudjEuUmV2b2tlQXBpS2V5UmVxdWVzdBofLmFwaWtleS52MS5SZXZva2VBcGlLZXlSZXNwb25zZUKoAQoNY29tLmFwaWtleS52MUILQXBpa2V5UHJvdG9QAVpFZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvYXBpa2V5L3YxO2FwaWtleXYxogIDQVhYqgIJQXBpa2V5LlYxygIJQXBpa2V5XFYx4gIVQXBpa2V5XFYxXEdQQk1ldGFkYXRh6gIKQXBpa2V5OjpWMWIGcHJvdG8z", + [file_google_protobuf_timestamp, file_buf_validate_validate], + ); + +/** + * @generated from message apikey.v1.CreateApiKeyRequest + */ +export type CreateApiKeyRequest = Message<"apikey.v1.CreateApiKeyRequest"> & { + /** + * Human-readable name for the API key + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Optional expiration timestamp. If not set, the key never expires. + * + * @generated from field: google.protobuf.Timestamp expires_at = 2; + */ + expiresAt?: Timestamp; +}; + +/** + * Describes the message apikey.v1.CreateApiKeyRequest. + * Use `create(CreateApiKeyRequestSchema)` to create a new message. + */ +export const CreateApiKeyRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 0); + +/** + * @generated from message apikey.v1.CreateApiKeyResponse + */ +export type CreateApiKeyResponse = Message<"apikey.v1.CreateApiKeyResponse"> & { + /** + * The full API key. This is shown ONCE and cannot be retrieved again. + * + * @generated from field: string api_key = 1; + */ + apiKey: string; + + /** + * API key metadata + * + * @generated from field: apikey.v1.ApiKeyInfo info = 2; + */ + info?: ApiKeyInfo; +}; + +/** + * Describes the message apikey.v1.CreateApiKeyResponse. + * Use `create(CreateApiKeyResponseSchema)` to create a new message. + */ +export const CreateApiKeyResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 1); + +/** + * @generated from message apikey.v1.ListApiKeysRequest + */ +export type ListApiKeysRequest = Message<"apikey.v1.ListApiKeysRequest"> & {}; + +/** + * Describes the message apikey.v1.ListApiKeysRequest. + * Use `create(ListApiKeysRequestSchema)` to create a new message. + */ +export const ListApiKeysRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 2); + +/** + * @generated from message apikey.v1.ListApiKeysResponse + */ +export type ListApiKeysResponse = Message<"apikey.v1.ListApiKeysResponse"> & { + /** + * @generated from field: repeated apikey.v1.ApiKeyInfo api_keys = 1; + */ + apiKeys: ApiKeyInfo[]; +}; + +/** + * Describes the message apikey.v1.ListApiKeysResponse. + * Use `create(ListApiKeysResponseSchema)` to create a new message. + */ +export const ListApiKeysResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 3); + +/** + * @generated from message apikey.v1.ApiKeyInfo + */ +export type ApiKeyInfo = Message<"apikey.v1.ApiKeyInfo"> & { + /** + * Unique identifier for this API key + * + * @generated from field: string key_id = 1; + */ + keyId: string; + + /** + * Human-readable name + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Key prefix for identification (e.g., "fleet_ab3f1e09...") + * + * @generated from field: string prefix = 3; + */ + prefix: string; + + /** + * Timestamp when the key was created + * + * @generated from field: google.protobuf.Timestamp created_at = 4; + */ + createdAt?: Timestamp; + + /** + * Optional expiration timestamp + * + * @generated from field: google.protobuf.Timestamp expires_at = 5; + */ + expiresAt?: Timestamp; + + /** + * Timestamp when the key was last used for authentication + * + * @generated from field: google.protobuf.Timestamp last_used_at = 6; + */ + lastUsedAt?: Timestamp; + + /** + * Username of the user who created the key + * + * @generated from field: string created_by = 7; + */ + createdBy: string; +}; + +/** + * Describes the message apikey.v1.ApiKeyInfo. + * Use `create(ApiKeyInfoSchema)` to create a new message. + */ +export const ApiKeyInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_apikey_v1_apikey, 4); + +/** + * @generated from message apikey.v1.RevokeApiKeyRequest + */ +export type RevokeApiKeyRequest = Message<"apikey.v1.RevokeApiKeyRequest"> & { + /** + * ID of the API key to revoke + * + * @generated from field: string key_id = 1; + */ + keyId: string; +}; + +/** + * Describes the message apikey.v1.RevokeApiKeyRequest. + * Use `create(RevokeApiKeyRequestSchema)` to create a new message. + */ +export const RevokeApiKeyRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 5); + +/** + * @generated from message apikey.v1.RevokeApiKeyResponse + */ +export type RevokeApiKeyResponse = Message<"apikey.v1.RevokeApiKeyResponse"> & {}; + +/** + * Describes the message apikey.v1.RevokeApiKeyResponse. + * Use `create(RevokeApiKeyResponseSchema)` to create a new message. + */ +export const RevokeApiKeyResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_apikey_v1_apikey, 6); + +/** + * ApiKeyService provides management of API keys for programmatic gRPC access + * + * @generated from service apikey.v1.ApiKeyService + */ +export const ApiKeyService: GenService<{ + /** + * CreateApiKey creates a new API key for the authenticated user's organization. + * The full key is returned once in the response and cannot be retrieved again. + * + * @generated from rpc apikey.v1.ApiKeyService.CreateApiKey + */ + createApiKey: { + methodKind: "unary"; + input: typeof CreateApiKeyRequestSchema; + output: typeof CreateApiKeyResponseSchema; + }; + /** + * ListApiKeys returns all active (non-revoked) API keys for the organization. + * + * @generated from rpc apikey.v1.ApiKeyService.ListApiKeys + */ + listApiKeys: { + methodKind: "unary"; + input: typeof ListApiKeysRequestSchema; + output: typeof ListApiKeysResponseSchema; + }; + /** + * RevokeApiKey permanently revokes an API key. The key cannot be used after revocation. + * + * @generated from rpc apikey.v1.ApiKeyService.RevokeApiKey + */ + revokeApiKey: { + methodKind: "unary"; + input: typeof RevokeApiKeyRequestSchema; + output: typeof RevokeApiKeyResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_apikey_v1_apikey, 0); diff --git a/client/src/protoFleet/api/generated/auth/v1/auth_pb.ts b/client/src/protoFleet/api/generated/auth/v1/auth_pb.ts new file mode 100644 index 000000000..e2335b171 --- /dev/null +++ b/client/src/protoFleet/api/generated/auth/v1/auth_pb.ts @@ -0,0 +1,660 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file auth/v1/auth.proto (package auth.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file auth/v1/auth.proto. + */ +export const file_auth_v1_auth: GenFile = + /*@__PURE__*/ + fileDesc( + "ChJhdXRoL3YxL2F1dGgucHJvdG8SB2F1dGgudjEiOQoTQXV0aGVudGljYXRlUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSJUChRBdXRoZW50aWNhdGVSZXNwb25zZRIkCgl1c2VyX2luZm8YASABKAsyES5hdXRoLnYxLlVzZXJJbmZvEhYKDnNlc3Npb25fZXhwaXJ5GAIgASgDIg8KDUxvZ291dFJlcXVlc3QiEAoOTG9nb3V0UmVzcG9uc2UiRwoVVXBkYXRlUGFzc3dvcmRSZXF1ZXN0EhgKEGN1cnJlbnRfcGFzc3dvcmQYASABKAkSFAoMbmV3X3Bhc3N3b3JkGAIgASgJIhgKFlVwZGF0ZVBhc3N3b3JkUmVzcG9uc2UiKQoVVXBkYXRlVXNlcm5hbWVSZXF1ZXN0EhAKCHVzZXJuYW1lGAEgASgJIhgKFlVwZGF0ZVVzZXJuYW1lUmVzcG9uc2UiGQoXR2V0VXNlckF1ZGl0SW5mb1JlcXVlc3QiSAoNVXNlckF1ZGl0SW5mbxI3ChNwYXNzd29yZF91cGRhdGVkX2F0GAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJAChhHZXRVc2VyQXVkaXRJbmZvUmVzcG9uc2USJAoEaW5mbxgBIAEoCzIWLmF1dGgudjEuVXNlckF1ZGl0SW5mbyIlChFDcmVhdGVVc2VyUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCSJTChJDcmVhdGVVc2VyUmVzcG9uc2USDwoHdXNlcl9pZBgBIAEoCRIQCgh1c2VybmFtZRgCIAEoCRIaChJ0ZW1wb3JhcnlfcGFzc3dvcmQYAyABKAkiEgoQTGlzdFVzZXJzUmVxdWVzdCLJAQoIVXNlckluZm8SDwoHdXNlcl9pZBgBIAEoCRIQCgh1c2VybmFtZRgCIAEoCRI3ChNwYXNzd29yZF91cGRhdGVkX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIxCg1sYXN0X2xvZ2luX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIMCgRyb2xlGAUgASgJEiAKGHJlcXVpcmVzX3Bhc3N3b3JkX2NoYW5nZRgGIAEoCCI1ChFMaXN0VXNlcnNSZXNwb25zZRIgCgV1c2VycxgBIAMoCzIRLmF1dGgudjEuVXNlckluZm8iKwoYUmVzZXRVc2VyUGFzc3dvcmRSZXF1ZXN0Eg8KB3VzZXJfaWQYASABKAkiNwoZUmVzZXRVc2VyUGFzc3dvcmRSZXNwb25zZRIaChJ0ZW1wb3JhcnlfcGFzc3dvcmQYASABKAkiKAoVRGVhY3RpdmF0ZVVzZXJSZXF1ZXN0Eg8KB3VzZXJfaWQYASABKAkiGAoWRGVhY3RpdmF0ZVVzZXJSZXNwb25zZSI+ChhWZXJpZnlDcmVkZW50aWFsc1JlcXVlc3QSEAoIdXNlcm5hbWUYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiGwoZVmVyaWZ5Q3JlZGVudGlhbHNSZXNwb25zZSp2ChVBdXRoZW50aWNhdGVFcnJvckNvZGUSJwojQVVUSEVOVElDQVRFX0VSUk9SX0NPREVfVU5TUEVDSUZJRUQQABI0CjBBVVRIRU5USUNBVEVfRVJST1JfQ09ERV9JTlZBTElEX1VTRVJfT1JfUEFTU1dPUkQQASq8AQoXVXBkYXRlUGFzc3dvcmRFcnJvckNvZGUSKgomVVBEQVRFX1BBU1NXT1JEX0VSUk9SX0NPREVfVU5TUEVDSUZJRUQQABIzCi9VUERBVEVfUEFTU1dPUkRfRVJST1JfQ09ERV9JTlZBTElEX09MRF9QQVNTV09SRBABEkAKPFVQREFURV9QQVNTV09SRF9FUlJPUl9DT0RFX05FV19QQVNTV09SRF9TQU1FX0FTX09MRF9QQVNTV09SRBACKtkBChdVc2VyTWFuYWdlbWVudEVycm9yQ29kZRIqCiZVU0VSX01BTkFHRU1FTlRfRVJST1JfQ09ERV9VTlNQRUNJRklFRBAAEisKJ1VTRVJfTUFOQUdFTUVOVF9FUlJPUl9DT0RFX1VOQVVUSE9SSVpFRBABEi4KKlVTRVJfTUFOQUdFTUVOVF9FUlJPUl9DT0RFX1VTRVJOQU1FX0VYSVNUUxACEjUKMVVTRVJfTUFOQUdFTUVOVF9FUlJPUl9DT0RFX0NBTk5PVF9ERUFDVElWQVRFX1NFTEYQAzKqBgoLQXV0aFNlcnZpY2USSwoMQXV0aGVudGljYXRlEhwuYXV0aC52MS5BdXRoZW50aWNhdGVSZXF1ZXN0Gh0uYXV0aC52MS5BdXRoZW50aWNhdGVSZXNwb25zZRI5CgZMb2dvdXQSFi5hdXRoLnYxLkxvZ291dFJlcXVlc3QaFy5hdXRoLnYxLkxvZ291dFJlc3BvbnNlElEKDlVwZGF0ZVBhc3N3b3JkEh4uYXV0aC52MS5VcGRhdGVQYXNzd29yZFJlcXVlc3QaHy5hdXRoLnYxLlVwZGF0ZVBhc3N3b3JkUmVzcG9uc2USUQoOVXBkYXRlVXNlcm5hbWUSHi5hdXRoLnYxLlVwZGF0ZVVzZXJuYW1lUmVxdWVzdBofLmF1dGgudjEuVXBkYXRlVXNlcm5hbWVSZXNwb25zZRJXChBHZXRVc2VyQXVkaXRJbmZvEiAuYXV0aC52MS5HZXRVc2VyQXVkaXRJbmZvUmVxdWVzdBohLmF1dGgudjEuR2V0VXNlckF1ZGl0SW5mb1Jlc3BvbnNlEkUKCkNyZWF0ZVVzZXISGi5hdXRoLnYxLkNyZWF0ZVVzZXJSZXF1ZXN0GhsuYXV0aC52MS5DcmVhdGVVc2VyUmVzcG9uc2USQgoJTGlzdFVzZXJzEhkuYXV0aC52MS5MaXN0VXNlcnNSZXF1ZXN0GhouYXV0aC52MS5MaXN0VXNlcnNSZXNwb25zZRJaChFSZXNldFVzZXJQYXNzd29yZBIhLmF1dGgudjEuUmVzZXRVc2VyUGFzc3dvcmRSZXF1ZXN0GiIuYXV0aC52MS5SZXNldFVzZXJQYXNzd29yZFJlc3BvbnNlElEKDkRlYWN0aXZhdGVVc2VyEh4uYXV0aC52MS5EZWFjdGl2YXRlVXNlclJlcXVlc3QaHy5hdXRoLnYxLkRlYWN0aXZhdGVVc2VyUmVzcG9uc2USWgoRVmVyaWZ5Q3JlZGVudGlhbHMSIS5hdXRoLnYxLlZlcmlmeUNyZWRlbnRpYWxzUmVxdWVzdBoiLmF1dGgudjEuVmVyaWZ5Q3JlZGVudGlhbHNSZXNwb25zZUKYAQoLY29tLmF1dGgudjFCCUF1dGhQcm90b1ABWkFnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9hdXRoL3YxO2F1dGh2MaICA0FYWKoCB0F1dGguVjHKAgdBdXRoXFYx4gITQXV0aFxWMVxHUEJNZXRhZGF0YeoCCEF1dGg6OlYxYgZwcm90bzM", + [file_google_protobuf_timestamp], + ); + +/** + * @generated from message auth.v1.AuthenticateRequest + */ +export type AuthenticateRequest = Message<"auth.v1.AuthenticateRequest"> & { + /** + * Username of the user attempting to authenticate + * + * @generated from field: string username = 1; + */ + username: string; + + /** + * Password for the user account + * + * @generated from field: string password = 2; + */ + password: string; +}; + +/** + * Describes the message auth.v1.AuthenticateRequest. + * Use `create(AuthenticateRequestSchema)` to create a new message. + */ +export const AuthenticateRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 0); + +/** + * @generated from message auth.v1.AuthenticateResponse + */ +export type AuthenticateResponse = Message<"auth.v1.AuthenticateResponse"> & { + /** + * Authenticated user information + * Contains user_id, username, role, password change requirements, and audit timestamps + * + * @generated from field: auth.v1.UserInfo user_info = 1; + */ + userInfo?: UserInfo; + + /** + * Unix timestamp (in seconds) indicating when the session will expire + * Session expiry is extended on each request (sliding window) + * + * @generated from field: int64 session_expiry = 2; + */ + sessionExpiry: bigint; +}; + +/** + * Describes the message auth.v1.AuthenticateResponse. + * Use `create(AuthenticateResponseSchema)` to create a new message. + */ +export const AuthenticateResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 1); + +/** + * @generated from message auth.v1.LogoutRequest + */ +export type LogoutRequest = Message<"auth.v1.LogoutRequest"> & {}; + +/** + * Describes the message auth.v1.LogoutRequest. + * Use `create(LogoutRequestSchema)` to create a new message. + */ +export const LogoutRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 2); + +/** + * @generated from message auth.v1.LogoutResponse + */ +export type LogoutResponse = Message<"auth.v1.LogoutResponse"> & {}; + +/** + * Describes the message auth.v1.LogoutResponse. + * Use `create(LogoutResponseSchema)` to create a new message. + */ +export const LogoutResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 3); + +/** + * @generated from message auth.v1.UpdatePasswordRequest + */ +export type UpdatePasswordRequest = Message<"auth.v1.UpdatePasswordRequest"> & { + /** + * Current password for the user account + * Must match the user's existing password + * + * @generated from field: string current_password = 1; + */ + currentPassword: string; + + /** + * New password for the user account + * + * @generated from field: string new_password = 2; + */ + newPassword: string; +}; + +/** + * Describes the message auth.v1.UpdatePasswordRequest. + * Use `create(UpdatePasswordRequestSchema)` to create a new message. + */ +export const UpdatePasswordRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 4); + +/** + * Empty response as success/failure is indicated by gRPC status + * A successful response indicates the password was changed + * + * @generated from message auth.v1.UpdatePasswordResponse + */ +export type UpdatePasswordResponse = Message<"auth.v1.UpdatePasswordResponse"> & {}; + +/** + * Describes the message auth.v1.UpdatePasswordResponse. + * Use `create(UpdatePasswordResponseSchema)` to create a new message. + */ +export const UpdatePasswordResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 5); + +/** + * @generated from message auth.v1.UpdateUsernameRequest + */ +export type UpdateUsernameRequest = Message<"auth.v1.UpdateUsernameRequest"> & { + /** + * @generated from field: string username = 1; + */ + username: string; +}; + +/** + * Describes the message auth.v1.UpdateUsernameRequest. + * Use `create(UpdateUsernameRequestSchema)` to create a new message. + */ +export const UpdateUsernameRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 6); + +/** + * @generated from message auth.v1.UpdateUsernameResponse + */ +export type UpdateUsernameResponse = Message<"auth.v1.UpdateUsernameResponse"> & {}; + +/** + * Describes the message auth.v1.UpdateUsernameResponse. + * Use `create(UpdateUsernameResponseSchema)` to create a new message. + */ +export const UpdateUsernameResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 7); + +/** + * @generated from message auth.v1.GetUserAuditInfoRequest + */ +export type GetUserAuditInfoRequest = Message<"auth.v1.GetUserAuditInfoRequest"> & {}; + +/** + * Describes the message auth.v1.GetUserAuditInfoRequest. + * Use `create(GetUserAuditInfoRequestSchema)` to create a new message. + */ +export const GetUserAuditInfoRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 8); + +/** + * @generated from message auth.v1.UserAuditInfo + */ +export type UserAuditInfo = Message<"auth.v1.UserAuditInfo"> & { + /** + * @generated from field: google.protobuf.Timestamp password_updated_at = 1; + */ + passwordUpdatedAt?: Timestamp; +}; + +/** + * Describes the message auth.v1.UserAuditInfo. + * Use `create(UserAuditInfoSchema)` to create a new message. + */ +export const UserAuditInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 9); + +/** + * @generated from message auth.v1.GetUserAuditInfoResponse + */ +export type GetUserAuditInfoResponse = Message<"auth.v1.GetUserAuditInfoResponse"> & { + /** + * @generated from field: auth.v1.UserAuditInfo info = 1; + */ + info?: UserAuditInfo; +}; + +/** + * Describes the message auth.v1.GetUserAuditInfoResponse. + * Use `create(GetUserAuditInfoResponseSchema)` to create a new message. + */ +export const GetUserAuditInfoResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 10); + +/** + * @generated from message auth.v1.CreateUserRequest + */ +export type CreateUserRequest = Message<"auth.v1.CreateUserRequest"> & { + /** + * Username for the new user account + * Must be unique within the system + * + * @generated from field: string username = 1; + */ + username: string; +}; + +/** + * Describes the message auth.v1.CreateUserRequest. + * Use `create(CreateUserRequestSchema)` to create a new message. + */ +export const CreateUserRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 11); + +/** + * @generated from message auth.v1.CreateUserResponse + */ +export type CreateUserResponse = Message<"auth.v1.CreateUserResponse"> & { + /** + * Unique identifier for the created user + * + * @generated from field: string user_id = 1; + */ + userId: string; + + /** + * Username of the created user + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Temporary password generated by the system + * This is only returned once and will not be accessible again + * + * @generated from field: string temporary_password = 3; + */ + temporaryPassword: string; +}; + +/** + * Describes the message auth.v1.CreateUserResponse. + * Use `create(CreateUserResponseSchema)` to create a new message. + */ +export const CreateUserResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 12); + +/** + * @generated from message auth.v1.ListUsersRequest + */ +export type ListUsersRequest = Message<"auth.v1.ListUsersRequest"> & {}; + +/** + * Describes the message auth.v1.ListUsersRequest. + * Use `create(ListUsersRequestSchema)` to create a new message. + */ +export const ListUsersRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 13); + +/** + * @generated from message auth.v1.UserInfo + */ +export type UserInfo = Message<"auth.v1.UserInfo"> & { + /** + * Unique identifier for the user + * + * @generated from field: string user_id = 1; + */ + userId: string; + + /** + * Username of the user + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Timestamp when the user's password was last updated + * + * @generated from field: google.protobuf.Timestamp password_updated_at = 3; + */ + passwordUpdatedAt?: Timestamp; + + /** + * Timestamp when the user last logged in + * May be null if the user has never logged in + * + * @generated from field: google.protobuf.Timestamp last_login_at = 4; + */ + lastLoginAt?: Timestamp; + + /** + * Role name + * + * @generated from field: string role = 5; + */ + role: string; + + /** + * Indicates whether the user must change their password + * + * @generated from field: bool requires_password_change = 6; + */ + requiresPasswordChange: boolean; +}; + +/** + * Describes the message auth.v1.UserInfo. + * Use `create(UserInfoSchema)` to create a new message. + */ +export const UserInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 14); + +/** + * @generated from message auth.v1.ListUsersResponse + */ +export type ListUsersResponse = Message<"auth.v1.ListUsersResponse"> & { + /** + * @generated from field: repeated auth.v1.UserInfo users = 1; + */ + users: UserInfo[]; +}; + +/** + * Describes the message auth.v1.ListUsersResponse. + * Use `create(ListUsersResponseSchema)` to create a new message. + */ +export const ListUsersResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_auth_v1_auth, 15); + +/** + * @generated from message auth.v1.ResetUserPasswordRequest + */ +export type ResetUserPasswordRequest = Message<"auth.v1.ResetUserPasswordRequest"> & { + /** + * Unique identifier of the user whose password should be reset + * + * @generated from field: string user_id = 1; + */ + userId: string; +}; + +/** + * Describes the message auth.v1.ResetUserPasswordRequest. + * Use `create(ResetUserPasswordRequestSchema)` to create a new message. + */ +export const ResetUserPasswordRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 16); + +/** + * @generated from message auth.v1.ResetUserPasswordResponse + */ +export type ResetUserPasswordResponse = Message<"auth.v1.ResetUserPasswordResponse"> & { + /** + * New temporary password generated by the system + * This is only returned once and will not be accessible again + * + * @generated from field: string temporary_password = 1; + */ + temporaryPassword: string; +}; + +/** + * Describes the message auth.v1.ResetUserPasswordResponse. + * Use `create(ResetUserPasswordResponseSchema)` to create a new message. + */ +export const ResetUserPasswordResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 17); + +/** + * @generated from message auth.v1.DeactivateUserRequest + */ +export type DeactivateUserRequest = Message<"auth.v1.DeactivateUserRequest"> & { + /** + * Unique identifier of the user to deactivate + * + * @generated from field: string user_id = 1; + */ + userId: string; +}; + +/** + * Describes the message auth.v1.DeactivateUserRequest. + * Use `create(DeactivateUserRequestSchema)` to create a new message. + */ +export const DeactivateUserRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 18); + +/** + * @generated from message auth.v1.DeactivateUserResponse + */ +export type DeactivateUserResponse = Message<"auth.v1.DeactivateUserResponse"> & {}; + +/** + * Describes the message auth.v1.DeactivateUserResponse. + * Use `create(DeactivateUserResponseSchema)` to create a new message. + */ +export const DeactivateUserResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 19); + +/** + * @generated from message auth.v1.VerifyCredentialsRequest + */ +export type VerifyCredentialsRequest = Message<"auth.v1.VerifyCredentialsRequest"> & { + /** + * Username — must match the currently authenticated session user + * + * @generated from field: string username = 1; + */ + username: string; + + /** + * Password for the currently authenticated user + * + * @generated from field: string password = 2; + */ + password: string; +}; + +/** + * Describes the message auth.v1.VerifyCredentialsRequest. + * Use `create(VerifyCredentialsRequestSchema)` to create a new message. + */ +export const VerifyCredentialsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 20); + +/** + * @generated from message auth.v1.VerifyCredentialsResponse + */ +export type VerifyCredentialsResponse = Message<"auth.v1.VerifyCredentialsResponse"> & {}; + +/** + * Describes the message auth.v1.VerifyCredentialsResponse. + * Use `create(VerifyCredentialsResponseSchema)` to create a new message. + */ +export const VerifyCredentialsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_auth_v1_auth, 21); + +/** + * @generated from enum auth.v1.AuthenticateErrorCode + */ +export enum AuthenticateErrorCode { + /** + * @generated from enum value: AUTHENTICATE_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHENTICATE_ERROR_CODE_INVALID_USER_OR_PASSWORD = 1; + */ + INVALID_USER_OR_PASSWORD = 1, +} + +/** + * Describes the enum auth.v1.AuthenticateErrorCode. + */ +export const AuthenticateErrorCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_auth_v1_auth, 0); + +/** + * @generated from enum auth.v1.UpdatePasswordErrorCode + */ +export enum UpdatePasswordErrorCode { + /** + * @generated from enum value: UPDATE_PASSWORD_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: UPDATE_PASSWORD_ERROR_CODE_INVALID_OLD_PASSWORD = 1; + */ + INVALID_OLD_PASSWORD = 1, + + /** + * @generated from enum value: UPDATE_PASSWORD_ERROR_CODE_NEW_PASSWORD_SAME_AS_OLD_PASSWORD = 2; + */ + NEW_PASSWORD_SAME_AS_OLD_PASSWORD = 2, +} + +/** + * Describes the enum auth.v1.UpdatePasswordErrorCode. + */ +export const UpdatePasswordErrorCodeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_auth_v1_auth, 1); + +/** + * @generated from enum auth.v1.UserManagementErrorCode + */ +export enum UserManagementErrorCode { + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_UNAUTHORIZED = 1; + */ + UNAUTHORIZED = 1, + + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_USERNAME_EXISTS = 2; + */ + USERNAME_EXISTS = 2, + + /** + * @generated from enum value: USER_MANAGEMENT_ERROR_CODE_CANNOT_DEACTIVATE_SELF = 3; + */ + CANNOT_DEACTIVATE_SELF = 3, +} + +/** + * Describes the enum auth.v1.UserManagementErrorCode. + */ +export const UserManagementErrorCodeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_auth_v1_auth, 2); + +/** + * AuthService provides authentication and user credential management functionality + * + * @generated from service auth.v1.AuthService + */ +export const AuthService: GenService<{ + /** + * Authenticate validates user credentials and creates a session + * Returns session information and sets a session cookie for subsequent requests + * + * @generated from rpc auth.v1.AuthService.Authenticate + */ + authenticate: { + methodKind: "unary"; + input: typeof AuthenticateRequestSchema; + output: typeof AuthenticateResponseSchema; + }; + /** + * Logout invalidates the current session + * + * @generated from rpc auth.v1.AuthService.Logout + */ + logout: { + methodKind: "unary"; + input: typeof LogoutRequestSchema; + output: typeof LogoutResponseSchema; + }; + /** + * UpdatePassword changes a user's password after verifying their current password + * Returns an error if the current password is incorrect + * The user must be authenticated to use this endpoint + * + * @generated from rpc auth.v1.AuthService.UpdatePassword + */ + updatePassword: { + methodKind: "unary"; + input: typeof UpdatePasswordRequestSchema; + output: typeof UpdatePasswordResponseSchema; + }; + /** + * @generated from rpc auth.v1.AuthService.UpdateUsername + */ + updateUsername: { + methodKind: "unary"; + input: typeof UpdateUsernameRequestSchema; + output: typeof UpdateUsernameResponseSchema; + }; + /** + * @generated from rpc auth.v1.AuthService.GetUserAuditInfo + */ + getUserAuditInfo: { + methodKind: "unary"; + input: typeof GetUserAuditInfoRequestSchema; + output: typeof GetUserAuditInfoResponseSchema; + }; + /** + * CreateUser creates a new user account with a system-generated temporary password (Super Admin only) + * The temporary password is only returned once and must be shared with the new user + * + * @generated from rpc auth.v1.AuthService.CreateUser + */ + createUser: { + methodKind: "unary"; + input: typeof CreateUserRequestSchema; + output: typeof CreateUserResponseSchema; + }; + /** + * ListUsers returns all active users in the organization (Super Admin only) + * + * @generated from rpc auth.v1.AuthService.ListUsers + */ + listUsers: { + methodKind: "unary"; + input: typeof ListUsersRequestSchema; + output: typeof ListUsersResponseSchema; + }; + /** + * ResetUserPassword generates a new temporary password for an existing user (Super Admin only) + * The temporary password is only returned once and must be shared with the user + * + * @generated from rpc auth.v1.AuthService.ResetUserPassword + */ + resetUserPassword: { + methodKind: "unary"; + input: typeof ResetUserPasswordRequestSchema; + output: typeof ResetUserPasswordResponseSchema; + }; + /** + * DeactivateUser performs a soft delete on a user account (Super Admin only) + * Users cannot deactivate themselves + * + * @generated from rpc auth.v1.AuthService.DeactivateUser + */ + deactivateUser: { + methodKind: "unary"; + input: typeof DeactivateUserRequestSchema; + output: typeof DeactivateUserResponseSchema; + }; + /** + * VerifyCredentials verifies the provided username and password match the current session user, + * without creating a new session. Used for step-up authentication on sensitive operations + * (e.g., editing pools, managing security). Both username and password must match the + * authenticated session user; mismatched or invalid credentials are rejected. + * + * @generated from rpc auth.v1.AuthService.VerifyCredentials + */ + verifyCredentials: { + methodKind: "unary"; + input: typeof VerifyCredentialsRequestSchema; + output: typeof VerifyCredentialsResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_auth_v1_auth, 0); diff --git a/client/src/protoFleet/api/generated/buf/validate/validate_pb.ts b/client/src/protoFleet/api/generated/buf/validate/validate_pb.ts new file mode 100644 index 000000000..8eb2aee79 --- /dev/null +++ b/client/src/protoFleet/api/generated/buf/validate/validate_pb.ts @@ -0,0 +1,4801 @@ +// Copyright 2023-2025 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file buf/validate/validate.proto (package buf.validate, syntax proto2) +/* eslint-disable */ + +import type { GenEnum, GenExtension, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, extDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { + Duration, + FieldDescriptorProto_Type, + FieldOptions, + MessageOptions, + OneofOptions, + Timestamp, +} from "@bufbuild/protobuf/wkt"; +import { + file_google_protobuf_descriptor, + file_google_protobuf_duration, + file_google_protobuf_timestamp, +} from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file buf/validate/validate.proto. + */ +export const file_buf_validate_validate: GenFile = + /*@__PURE__*/ + fileDesc( + "ChtidWYvdmFsaWRhdGUvdmFsaWRhdGUucHJvdG8SDGJ1Zi52YWxpZGF0ZSI3CgRSdWxlEgoKAmlkGAEgASgJEg8KB21lc3NhZ2UYAiABKAkSEgoKZXhwcmVzc2lvbhgDIAEoCSJBCgxNZXNzYWdlUnVsZXMSEAoIZGlzYWJsZWQYASABKAgSHwoDY2VsGAMgAygLMhIuYnVmLnZhbGlkYXRlLlJ1bGUiHgoKT25lb2ZSdWxlcxIQCghyZXF1aXJlZBgBIAEoCCK/CAoKRmllbGRSdWxlcxIfCgNjZWwYFyADKAsyEi5idWYudmFsaWRhdGUuUnVsZRIQCghyZXF1aXJlZBgZIAEoCBIkCgZpZ25vcmUYGyABKA4yFC5idWYudmFsaWRhdGUuSWdub3JlEikKBWZsb2F0GAEgASgLMhguYnVmLnZhbGlkYXRlLkZsb2F0UnVsZXNIABIrCgZkb3VibGUYAiABKAsyGS5idWYudmFsaWRhdGUuRG91YmxlUnVsZXNIABIpCgVpbnQzMhgDIAEoCzIYLmJ1Zi52YWxpZGF0ZS5JbnQzMlJ1bGVzSAASKQoFaW50NjQYBCABKAsyGC5idWYudmFsaWRhdGUuSW50NjRSdWxlc0gAEisKBnVpbnQzMhgFIAEoCzIZLmJ1Zi52YWxpZGF0ZS5VSW50MzJSdWxlc0gAEisKBnVpbnQ2NBgGIAEoCzIZLmJ1Zi52YWxpZGF0ZS5VSW50NjRSdWxlc0gAEisKBnNpbnQzMhgHIAEoCzIZLmJ1Zi52YWxpZGF0ZS5TSW50MzJSdWxlc0gAEisKBnNpbnQ2NBgIIAEoCzIZLmJ1Zi52YWxpZGF0ZS5TSW50NjRSdWxlc0gAEi0KB2ZpeGVkMzIYCSABKAsyGi5idWYudmFsaWRhdGUuRml4ZWQzMlJ1bGVzSAASLQoHZml4ZWQ2NBgKIAEoCzIaLmJ1Zi52YWxpZGF0ZS5GaXhlZDY0UnVsZXNIABIvCghzZml4ZWQzMhgLIAEoCzIbLmJ1Zi52YWxpZGF0ZS5TRml4ZWQzMlJ1bGVzSAASLwoIc2ZpeGVkNjQYDCABKAsyGy5idWYudmFsaWRhdGUuU0ZpeGVkNjRSdWxlc0gAEicKBGJvb2wYDSABKAsyFy5idWYudmFsaWRhdGUuQm9vbFJ1bGVzSAASKwoGc3RyaW5nGA4gASgLMhkuYnVmLnZhbGlkYXRlLlN0cmluZ1J1bGVzSAASKQoFYnl0ZXMYDyABKAsyGC5idWYudmFsaWRhdGUuQnl0ZXNSdWxlc0gAEicKBGVudW0YECABKAsyFy5idWYudmFsaWRhdGUuRW51bVJ1bGVzSAASLwoIcmVwZWF0ZWQYEiABKAsyGy5idWYudmFsaWRhdGUuUmVwZWF0ZWRSdWxlc0gAEiUKA21hcBgTIAEoCzIWLmJ1Zi52YWxpZGF0ZS5NYXBSdWxlc0gAEiUKA2FueRgUIAEoCzIWLmJ1Zi52YWxpZGF0ZS5BbnlSdWxlc0gAEi8KCGR1cmF0aW9uGBUgASgLMhsuYnVmLnZhbGlkYXRlLkR1cmF0aW9uUnVsZXNIABIxCgl0aW1lc3RhbXAYFiABKAsyHC5idWYudmFsaWRhdGUuVGltZXN0YW1wUnVsZXNIAEIGCgR0eXBlSgQIGBAZSgQIGhAbUgdza2lwcGVkUgxpZ25vcmVfZW1wdHkiUwoPUHJlZGVmaW5lZFJ1bGVzEh8KA2NlbBgBIAMoCzISLmJ1Zi52YWxpZGF0ZS5SdWxlSgQIGBAZSgQIGhAbUhNza2lwcGVkaWdub3JlX2VtcHR5ItoXCgpGbG9hdFJ1bGVzEoMBCgVjb25zdBgBIAEoAkJ0wkhxCm8KC2Zsb2F0LmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSnwEKAmx0GAIgASgCQpABwkiMAQqJAQoIZmxvYXQubHQafSFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPj0gcnVsZXMubHQpPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASrwEKA2x0ZRgDIAEoAkKfAcJImwEKmAEKCWZsb2F0Lmx0ZRqKASFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPiBydWxlcy5sdGUpPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEu8HCgJndBgEIAEoAkLgB8JI3AcKjQEKCGZsb2F0Lmd0GoABIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKwwEKC2Zsb2F0Lmd0X2x0GrMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKzQEKFWZsb2F0Lmd0X2x0X2V4Y2x1c2l2ZRqzAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCtMBCgxmbG9hdC5ndF9sdGUawgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrdAQoWZmxvYXQuZ3RfbHRlX2V4Y2x1c2l2ZRrCAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCkpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAESuggKA2d0ZRgFIAEoAkKqCMJIpggKmwEKCWZsb2F0Lmd0ZRqNASFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiAodGhpcy5pc05hbigpIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrSAQoMZmxvYXQuZ3RlX2x0GsEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrcAQoWZmxvYXQuZ3RlX2x0X2V4Y2x1c2l2ZRrBAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycK4gEKDWZsb2F0Lmd0ZV9sdGUa0AFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCuwBChdmbG9hdC5ndGVfbHRlX2V4Y2x1c2l2ZRrQAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARJ/CgJpbhgGIAMoAkJzwkhwCm4KCGZsb2F0LmluGmIhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ2CgZub3RfaW4YByADKAJCZsJIYwphCgxmbG9hdC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxJ1CgZmaW5pdGUYCCABKAhCZcJIYgpgCgxmbG9hdC5maW5pdGUaUHJ1bGVzLmZpbml0ZSA/ICh0aGlzLmlzTmFuKCkgfHwgdGhpcy5pc0luZigpID8gJ3ZhbHVlIG11c3QgYmUgZmluaXRlJyA6ICcnKSA6ICcnEisKB2V4YW1wbGUYCSADKAJCGsJIFwoVCg1mbG9hdC5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCCwoJbGVzc190aGFuQg4KDGdyZWF0ZXJfdGhhbiLtFwoLRG91YmxlUnVsZXMShAEKBWNvbnN0GAEgASgBQnXCSHIKcAoMZG91YmxlLmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSoAEKAmx0GAIgASgBQpEBwkiNAQqKAQoJZG91YmxlLmx0Gn0haGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0KT8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAErABCgNsdGUYAyABKAFCoAHCSJwBCpkBCgpkb3VibGUubHRlGooBIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA+IHJ1bGVzLmx0ZSk/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAAS9AcKAmd0GAQgASgBQuUHwkjhBwqOAQoJZG91YmxlLmd0GoABIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKxAEKDGRvdWJsZS5ndF9sdBqzAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCs4BChZkb3VibGUuZ3RfbHRfZXhjbHVzaXZlGrMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCkpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycK1AEKDWRvdWJsZS5ndF9sdGUawgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwreAQoXZG91YmxlLmd0X2x0ZV9leGNsdXNpdmUawgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEr8ICgNndGUYBSABKAFCrwjCSKsICpwBCgpkb3VibGUuZ3RlGo0BIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmICh0aGlzLmlzTmFuKCkgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCtMBCg1kb3VibGUuZ3RlX2x0GsEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrdAQoXZG91YmxlLmd0ZV9sdF9leGNsdXNpdmUawQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmICh0aGlzLmlzTmFuKCkgfHwgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSkpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCuMBCg5kb3VibGUuZ3RlX2x0ZRrQAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMuaXNOYW4oKSB8fCB0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK7QEKGGRvdWJsZS5ndGVfbHRlX2V4Y2x1c2l2ZRrQAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAodGhpcy5pc05hbigpIHx8IChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKAFCdMJIcQpvCglkb3VibGUuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoAUJnwkhkCmIKDWRvdWJsZS5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxJ2CgZmaW5pdGUYCCABKAhCZsJIYwphCg1kb3VibGUuZmluaXRlGlBydWxlcy5maW5pdGUgPyAodGhpcy5pc05hbigpIHx8IHRoaXMuaXNJbmYoKSA/ICd2YWx1ZSBtdXN0IGJlIGZpbml0ZScgOiAnJykgOiAnJxIsCgdleGFtcGxlGAkgAygBQhvCSBgKFgoOZG91YmxlLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIowVCgpJbnQzMlJ1bGVzEoMBCgVjb25zdBgBIAEoBUJ0wkhxCm8KC2ludDMyLmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSigEKAmx0GAIgASgFQnzCSHkKdwoIaW50MzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnAEKA2x0ZRgDIAEoBUKMAcJIiAEKhQEKCWludDMyLmx0ZRp4IWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASlwcKAmd0GAQgASgFQogHwkiEBwp6CghpbnQzMi5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKswEKC2ludDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq7AQoVaW50MzIuZ3RfbHRfZXhjbHVzaXZlGqEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKwwEKDGludDMyLmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKywEKFmludDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEuMHCgNndGUYBSABKAVC0wfCSM8HCogBCglpbnQzMi5ndGUaeyFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrCAQoMaW50MzIuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCsoBChZpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrSAQoNaW50MzIuZ3RlX2x0ZRrAAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwraAQoXaW50MzIuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESfwoCaW4YBiADKAVCc8JIcApuCghpbnQzMi5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSdgoGbm90X2luGAcgAygFQmbCSGMKYQoMaW50MzIubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSKwoHZXhhbXBsZRgIIAMoBUIawkgXChUKDWludDMyLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIowVCgpJbnQ2NFJ1bGVzEoMBCgVjb25zdBgBIAEoA0J0wkhxCm8KC2ludDY0LmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSigEKAmx0GAIgASgDQnzCSHkKdwoIaW50NjQubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnAEKA2x0ZRgDIAEoA0KMAcJIiAEKhQEKCWludDY0Lmx0ZRp4IWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASlwcKAmd0GAQgASgDQogHwkiEBwp6CghpbnQ2NC5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKswEKC2ludDY0Lmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq7AQoVaW50NjQuZ3RfbHRfZXhjbHVzaXZlGqEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKwwEKDGludDY0Lmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKywEKFmludDY0Lmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEuMHCgNndGUYBSABKANC0wfCSM8HCogBCglpbnQ2NC5ndGUaeyFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrCAQoMaW50NjQuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCsoBChZpbnQ2NC5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrSAQoNaW50NjQuZ3RlX2x0ZRrAAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwraAQoXaW50NjQuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESfwoCaW4YBiADKANCc8JIcApuCghpbnQ2NC5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSdgoGbm90X2luGAcgAygDQmbCSGMKYQoMaW50NjQubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSKwoHZXhhbXBsZRgJIAMoA0IawkgXChUKDWludDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtVSW50MzJSdWxlcxKEAQoFY29uc3QYASABKA1CdcJIcgpwCgx1aW50MzIuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKA1CfcJIegp4Cgl1aW50MzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoDUKNAcJIiQEKhgEKCnVpbnQzMi5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoDUKNB8JIiQcKewoJdWludDMyLmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMdWludDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWdWludDMyLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg11aW50MzIuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXdWludDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKA1C2AfCSNQHCokBCgp1aW50MzIuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXVpbnQzMi5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3VpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOdWludDMyLmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHVpbnQzMi5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKA1CdMJIcQpvCgl1aW50MzIuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoDUJnwkhkCmIKDXVpbnQzMi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygNQhvCSBgKFgoOdWludDMyLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtVSW50NjRSdWxlcxKEAQoFY29uc3QYASABKARCdcJIcgpwCgx1aW50NjQuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKARCfcJIegp4Cgl1aW50NjQubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoBEKNAcJIiQEKhgEKCnVpbnQ2NC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoBEKNB8JIiQcKewoJdWludDY0Lmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMdWludDY0Lmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWdWludDY0Lmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg11aW50NjQuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXdWludDY0Lmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKARC2AfCSNQHCokBCgp1aW50NjQuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXVpbnQ2NC5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3VpbnQ2NC5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOdWludDY0Lmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHVpbnQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKARCdMJIcQpvCgl1aW50NjQuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoBEJnwkhkCmIKDXVpbnQ2NC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygEQhvCSBgKFgoOdWludDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtTSW50MzJSdWxlcxKEAQoFY29uc3QYASABKBFCdcJIcgpwCgxzaW50MzIuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKBFCfcJIegp4CglzaW50MzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoEUKNAcJIiQEKhgEKCnNpbnQzMi5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoEUKNB8JIiQcKewoJc2ludDMyLmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMc2ludDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWc2ludDMyLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg1zaW50MzIuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXc2ludDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKBFC2AfCSNQHCokBCgpzaW50MzIuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXNpbnQzMi5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3NpbnQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOc2ludDMyLmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHNpbnQzMi5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKBFCdMJIcQpvCglzaW50MzIuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoEUJnwkhkCmIKDXNpbnQzMi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygRQhvCSBgKFgoOc2ludDMyLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIp4VCgtTSW50NjRSdWxlcxKEAQoFY29uc3QYASABKBJCdcJIcgpwCgxzaW50NjQuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKLAQoCbHQYAiABKBJCfcJIegp4CglzaW50NjQubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASnQEKA2x0ZRgDIAEoEkKNAcJIiQEKhgEKCnNpbnQ2NC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEpwHCgJndBgEIAEoEkKNB8JIiQcKewoJc2ludDY0Lmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq0AQoMc2ludDY0Lmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq8AQoWc2ludDY0Lmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsQBCg1zaW50NjQuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrMAQoXc2ludDY0Lmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEugHCgNndGUYBSABKBJC2AfCSNQHCokBCgpzaW50NjQuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKwwEKDXNpbnQ2NC5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKywEKF3NpbnQ2NC5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrTAQoOc2ludDY0Lmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK2wEKGHNpbnQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKAAQoCaW4YBiADKBJCdMJIcQpvCglzaW50NjQuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgHIAMoEkJnwkhkCmIKDXNpbnQ2NC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIsCgdleGFtcGxlGAggAygSQhvCSBgKFgoOc2ludDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIq8VCgxGaXhlZDMyUnVsZXMShQEKBWNvbnN0GAEgASgHQnbCSHMKcQoNZml4ZWQzMi5jb25zdBpgdGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEowBCgJsdBgCIAEoB0J+wkh7CnkKCmZpeGVkMzIubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASngEKA2x0ZRgDIAEoB0KOAcJIigEKhwEKC2ZpeGVkMzIubHRlGnghaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+IHJ1bGVzLmx0ZT8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmx0ZV0pIDogJydIABKhBwoCZ3QYBCABKAdCkgfCSI4HCnwKCmZpeGVkMzIuZ3QabiFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDw9IHJ1bGVzLmd0PyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RdKSA6ICcnCrUBCg1maXhlZDMyLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq9AQoXZml4ZWQzMi5ndF9sdF9leGNsdXNpdmUaoQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwrFAQoOZml4ZWQzMi5ndF9sdGUasgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCs0BChhmaXhlZDMyLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEu0HCgNndGUYBSABKAdC3QfCSNkHCooBCgtmaXhlZDMyLmd0ZRp7IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCsQBCg5maXhlZDMyLmd0ZV9sdBqxAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3RlICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrMAQoYZml4ZWQzMi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrUAQoPZml4ZWQzMi5ndGVfbHRlGsABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCtwBChlmaXhlZDMyLmd0ZV9sdGVfZXhjbHVzaXZlGr4BaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJ0gBEoEBCgJpbhgGIAMoB0J1wkhyCnAKCmZpeGVkMzIuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEngKBm5vdF9pbhgHIAMoB0JowkhlCmMKDmZpeGVkMzIubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSLQoHZXhhbXBsZRgIIAMoB0IcwkgZChcKD2ZpeGVkMzIuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4irxUKDEZpeGVkNjRSdWxlcxKFAQoFY29uc3QYASABKAZCdsJIcwpxCg1maXhlZDY0LmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSjAEKAmx0GAIgASgGQn7CSHsKeQoKZml4ZWQ2NC5sdBprIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKeAQoDbHRlGAMgASgGQo4BwkiKAQqHAQoLZml4ZWQ2NC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEqEHCgJndBgEIAEoBkKSB8JIjgcKfAoKZml4ZWQ2NC5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKtQEKDWZpeGVkNjQuZ3RfbHQaowFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCr0BChdmaXhlZDY0Lmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsUBCg5maXhlZDY0Lmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKzQEKGGZpeGVkNjQuZ3RfbHRlX2V4Y2x1c2l2ZRqwAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAES7QcKA2d0ZRgFIAEoBkLdB8JI2QcKigEKC2ZpeGVkNjQuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKxAEKDmZpeGVkNjQuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCswBChhmaXhlZDY0Lmd0ZV9sdF9leGNsdXNpdmUarwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCtQBCg9maXhlZDY0Lmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK3AEKGWZpeGVkNjQuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESgQEKAmluGAYgAygGQnXCSHIKcAoKZml4ZWQ2NC5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSeAoGbm90X2luGAcgAygGQmjCSGUKYwoOZml4ZWQ2NC5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxItCgdleGFtcGxlGAggAygGQhzCSBkKFwoPZml4ZWQ2NC5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCCwoJbGVzc190aGFuQg4KDGdyZWF0ZXJfdGhhbiLAFQoNU0ZpeGVkMzJSdWxlcxKGAQoFY29uc3QYASABKA9Cd8JIdApyCg5zZml4ZWQzMi5jb25zdBpgdGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEo0BCgJsdBgCIAEoD0J/wkh8CnoKC3NmaXhlZDMyLmx0GmshaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+PSBydWxlcy5sdD8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmx0XSkgOiAnJ0gAEp8BCgNsdGUYAyABKA9CjwHCSIsBCogBCgxzZml4ZWQzMi5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEqYHCgJndBgEIAEoD0KXB8JIkwcKfQoLc2ZpeGVkMzIuZ3QabiFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDw9IHJ1bGVzLmd0PyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RdKSA6ICcnCrYBCg5zZml4ZWQzMi5ndF9sdBqjAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKvgEKGHNmaXhlZDMyLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCsYBCg9zZml4ZWQzMi5ndF9sdGUasgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3QgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnCs4BChlzZml4ZWQzMi5ndF9sdGVfZXhjbHVzaXZlGrABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJydIARLyBwoDZ3RlGAUgASgPQuIHwkjeBwqLAQoMc2ZpeGVkMzIuZ3RlGnshaGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8IHJ1bGVzLmd0ZT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZV0pIDogJycKxQEKD3NmaXhlZDMyLmd0ZV9sdBqxAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPj0gcnVsZXMuZ3RlICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrNAQoZc2ZpeGVkMzIuZ3RlX2x0X2V4Y2x1c2l2ZRqvAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycK1QEKEHNmaXhlZDMyLmd0ZV9sdGUawAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPj0gcnVsZXMuZ3RlICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJycK3QEKGnNmaXhlZDMyLmd0ZV9sdGVfZXhjbHVzaXZlGr4BaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlIDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJ0gBEoIBCgJpbhgGIAMoD0J2wkhzCnEKC3NmaXhlZDMyLmluGmIhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ5CgZub3RfaW4YByADKA9CacJIZgpkCg9zZml4ZWQzMi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxIuCgdleGFtcGxlGAggAygPQh3CSBoKGAoQc2ZpeGVkMzIuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4iwBUKDVNGaXhlZDY0UnVsZXMShgEKBWNvbnN0GAEgASgQQnfCSHQKcgoOc2ZpeGVkNjQuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKNAQoCbHQYAiABKBBCf8JIfAp6CgtzZml4ZWQ2NC5sdBprIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABKfAQoDbHRlGAMgASgQQo8BwkiLAQqIAQoMc2ZpeGVkNjQubHRlGnghaGFzKHJ1bGVzLmd0ZSkgJiYgIWhhcyhydWxlcy5ndCkgJiYgdGhpcyA+IHJ1bGVzLmx0ZT8gJ3ZhbHVlIG11c3QgYmUgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmx0ZV0pIDogJydIABKmBwoCZ3QYBCABKBBClwfCSJMHCn0KC3NmaXhlZDY0Lmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq2AQoOc2ZpeGVkNjQuZ3RfbHQaowFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ICYmICh0aGlzID49IHJ1bGVzLmx0IHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCr4BChhzZml4ZWQ2NC5ndF9sdF9leGNsdXNpdmUaoQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3QgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8PSBydWxlcy5ndCk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwrGAQoPc2ZpeGVkNjQuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrOAQoZc2ZpeGVkNjQuZ3RfbHRlX2V4Y2x1c2l2ZRqwAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdGUgPCB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdGVdKSA6ICcnSAES8gcKA2d0ZRgFIAEoEELiB8JI3gcKiwEKDHNmaXhlZDY0Lmd0ZRp7IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCsUBCg9zZml4ZWQ2NC5ndGVfbHQasQFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0ID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycKzQEKGXNmaXhlZDY0Lmd0ZV9sdF9leGNsdXNpdmUarwFoYXMocnVsZXMubHQpICYmIHJ1bGVzLmx0IDwgcnVsZXMuZ3RlICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPCBydWxlcy5ndGUpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCtUBChBzZml4ZWQ2NC5ndGVfbHRlGsABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCt0BChpzZml4ZWQ2NC5ndGVfbHRlX2V4Y2x1c2l2ZRq+AWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0ZV0pIDogJydIARKCAQoCaW4YBiADKBBCdsJIcwpxCgtzZml4ZWQ2NC5pbhpiISh0aGlzIGluIGdldEZpZWxkKHJ1bGVzLCAnaW4nKSkgPyAndmFsdWUgbXVzdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnaW4nKV0pIDogJycSeQoGbm90X2luGAcgAygQQmnCSGYKZAoPc2ZpeGVkNjQubm90X2luGlF0aGlzIGluIHJ1bGVzLm5vdF9pbiA/ICd2YWx1ZSBtdXN0IG5vdCBiZSBpbiBsaXN0ICVzJy5mb3JtYXQoW3J1bGVzLm5vdF9pbl0pIDogJycSLgoHZXhhbXBsZRgIIAMoEEIdwkgaChgKEHNmaXhlZDY0LmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAkILCglsZXNzX3RoYW5CDgoMZ3JlYXRlcl90aGFuIscBCglCb29sUnVsZXMSggEKBWNvbnN0GAEgASgIQnPCSHAKbgoKYm9vbC5jb25zdBpgdGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBlcXVhbCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEioKB2V4YW1wbGUYAiADKAhCGcJIFgoUCgxib29sLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAiKQNwoLU3RyaW5nUnVsZXMShgEKBWNvbnN0GAEgASgJQnfCSHQKcgoMc3RyaW5nLmNvbnN0GmJ0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsIGAlc2AnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxJ+CgNsZW4YEyABKARCccJIbgpsCgpzdHJpbmcubGVuGl51aW50KHRoaXMuc2l6ZSgpKSAhPSBydWxlcy5sZW4gPyAndmFsdWUgbGVuZ3RoIG11c3QgYmUgJXMgY2hhcmFjdGVycycuZm9ybWF0KFtydWxlcy5sZW5dKSA6ICcnEpkBCgdtaW5fbGVuGAIgASgEQocBwkiDAQqAAQoOc3RyaW5nLm1pbl9sZW4abnVpbnQodGhpcy5zaXplKCkpIDwgcnVsZXMubWluX2xlbiA/ICd2YWx1ZSBsZW5ndGggbXVzdCBiZSBhdCBsZWFzdCAlcyBjaGFyYWN0ZXJzJy5mb3JtYXQoW3J1bGVzLm1pbl9sZW5dKSA6ICcnEpcBCgdtYXhfbGVuGAMgASgEQoUBwkiBAQp/Cg5zdHJpbmcubWF4X2xlbhptdWludCh0aGlzLnNpemUoKSkgPiBydWxlcy5tYXhfbGVuID8gJ3ZhbHVlIGxlbmd0aCBtdXN0IGJlIGF0IG1vc3QgJXMgY2hhcmFjdGVycycuZm9ybWF0KFtydWxlcy5tYXhfbGVuXSkgOiAnJxKbAQoJbGVuX2J5dGVzGBQgASgEQocBwkiDAQqAAQoQc3RyaW5nLmxlbl9ieXRlcxpsdWludChieXRlcyh0aGlzKS5zaXplKCkpICE9IHJ1bGVzLmxlbl9ieXRlcyA/ICd2YWx1ZSBsZW5ndGggbXVzdCBiZSAlcyBieXRlcycuZm9ybWF0KFtydWxlcy5sZW5fYnl0ZXNdKSA6ICcnEqMBCgltaW5fYnl0ZXMYBCABKARCjwHCSIsBCogBChBzdHJpbmcubWluX2J5dGVzGnR1aW50KGJ5dGVzKHRoaXMpLnNpemUoKSkgPCBydWxlcy5taW5fYnl0ZXMgPyAndmFsdWUgbGVuZ3RoIG11c3QgYmUgYXQgbGVhc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWluX2J5dGVzXSkgOiAnJxKiAQoJbWF4X2J5dGVzGAUgASgEQo4BwkiKAQqHAQoQc3RyaW5nLm1heF9ieXRlcxpzdWludChieXRlcyh0aGlzKS5zaXplKCkpID4gcnVsZXMubWF4X2J5dGVzID8gJ3ZhbHVlIGxlbmd0aCBtdXN0IGJlIGF0IG1vc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWF4X2J5dGVzXSkgOiAnJxKNAQoHcGF0dGVybhgGIAEoCUJ8wkh5CncKDnN0cmluZy5wYXR0ZXJuGmUhdGhpcy5tYXRjaGVzKHJ1bGVzLnBhdHRlcm4pID8gJ3ZhbHVlIGRvZXMgbm90IG1hdGNoIHJlZ2V4IHBhdHRlcm4gYCVzYCcuZm9ybWF0KFtydWxlcy5wYXR0ZXJuXSkgOiAnJxKEAQoGcHJlZml4GAcgASgJQnTCSHEKbwoNc3RyaW5nLnByZWZpeBpeIXRoaXMuc3RhcnRzV2l0aChydWxlcy5wcmVmaXgpID8gJ3ZhbHVlIGRvZXMgbm90IGhhdmUgcHJlZml4IGAlc2AnLmZvcm1hdChbcnVsZXMucHJlZml4XSkgOiAnJxKCAQoGc3VmZml4GAggASgJQnLCSG8KbQoNc3RyaW5nLnN1ZmZpeBpcIXRoaXMuZW5kc1dpdGgocnVsZXMuc3VmZml4KSA/ICd2YWx1ZSBkb2VzIG5vdCBoYXZlIHN1ZmZpeCBgJXNgJy5mb3JtYXQoW3J1bGVzLnN1ZmZpeF0pIDogJycSkAEKCGNvbnRhaW5zGAkgASgJQn7CSHsKeQoPc3RyaW5nLmNvbnRhaW5zGmYhdGhpcy5jb250YWlucyhydWxlcy5jb250YWlucykgPyAndmFsdWUgZG9lcyBub3QgY29udGFpbiBzdWJzdHJpbmcgYCVzYCcuZm9ybWF0KFtydWxlcy5jb250YWluc10pIDogJycSmAEKDG5vdF9jb250YWlucxgXIAEoCUKBAcJIfgp8ChNzdHJpbmcubm90X2NvbnRhaW5zGmV0aGlzLmNvbnRhaW5zKHJ1bGVzLm5vdF9jb250YWlucykgPyAndmFsdWUgY29udGFpbnMgc3Vic3RyaW5nIGAlc2AnLmZvcm1hdChbcnVsZXMubm90X2NvbnRhaW5zXSkgOiAnJxKAAQoCaW4YCiADKAlCdMJIcQpvCglzdHJpbmcuaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEncKBm5vdF9pbhgLIAMoCUJnwkhkCmIKDXN0cmluZy5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxLfAQoFZW1haWwYDCABKAhCzQHCSMkBCmEKDHN0cmluZy5lbWFpbBIjdmFsdWUgbXVzdCBiZSBhIHZhbGlkIGVtYWlsIGFkZHJlc3MaLCFydWxlcy5lbWFpbCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNFbWFpbCgpCmQKEnN0cmluZy5lbWFpbF9lbXB0eRIydmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIGVtYWlsIGFkZHJlc3MaGiFydWxlcy5lbWFpbCB8fCB0aGlzICE9ICcnSAAS5wEKCGhvc3RuYW1lGA0gASgIQtIBwkjOAQplCg9zdHJpbmcuaG9zdG5hbWUSHnZhbHVlIG11c3QgYmUgYSB2YWxpZCBob3N0bmFtZRoyIXJ1bGVzLmhvc3RuYW1lIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0hvc3RuYW1lKCkKZQoVc3RyaW5nLmhvc3RuYW1lX2VtcHR5Ei12YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgaG9zdG5hbWUaHSFydWxlcy5ob3N0bmFtZSB8fCB0aGlzICE9ICcnSAASxwEKAmlwGA4gASgIQrgBwki0AQpVCglzdHJpbmcuaXASIHZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUCBhZGRyZXNzGiYhcnVsZXMuaXAgfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXAoKQpbCg9zdHJpbmcuaXBfZW1wdHkSL3ZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUCBhZGRyZXNzGhchcnVsZXMuaXAgfHwgdGhpcyAhPSAnJ0gAEtYBCgRpcHY0GA8gASgIQsUBwkjBAQpcCgtzdHJpbmcuaXB2NBIidmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQdjQgYWRkcmVzcxopIXJ1bGVzLmlwdjQgfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXAoNCkKYQoRc3RyaW5nLmlwdjRfZW1wdHkSMXZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY0IGFkZHJlc3MaGSFydWxlcy5pcHY0IHx8IHRoaXMgIT0gJydIABLWAQoEaXB2NhgQIAEoCELFAcJIwQEKXAoLc3RyaW5nLmlwdjYSInZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY2IGFkZHJlc3MaKSFydWxlcy5pcHY2IHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwKDYpCmEKEXN0cmluZy5pcHY2X2VtcHR5EjF2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NiBhZGRyZXNzGhkhcnVsZXMuaXB2NiB8fCB0aGlzICE9ICcnSAASvwEKA3VyaRgRIAEoCEKvAcJIqwEKUQoKc3RyaW5nLnVyaRIZdmFsdWUgbXVzdCBiZSBhIHZhbGlkIFVSSRooIXJ1bGVzLnVyaSB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNVcmkoKQpWChBzdHJpbmcudXJpX2VtcHR5Eih2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgVVJJGhghcnVsZXMudXJpIHx8IHRoaXMgIT0gJydIABJwCgd1cmlfcmVmGBIgASgIQl3CSFoKWAoOc3RyaW5nLnVyaV9yZWYSI3ZhbHVlIG11c3QgYmUgYSB2YWxpZCBVUkkgUmVmZXJlbmNlGiEhcnVsZXMudXJpX3JlZiB8fCB0aGlzLmlzVXJpUmVmKClIABKQAgoHYWRkcmVzcxgVIAEoCEL8AcJI+AEKgQEKDnN0cmluZy5hZGRyZXNzEi12YWx1ZSBtdXN0IGJlIGEgdmFsaWQgaG9zdG5hbWUsIG9yIGlwIGFkZHJlc3MaQCFydWxlcy5hZGRyZXNzIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0hvc3RuYW1lKCkgfHwgdGhpcy5pc0lwKCkKcgoUc3RyaW5nLmFkZHJlc3NfZW1wdHkSPHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBob3N0bmFtZSwgb3IgaXAgYWRkcmVzcxocIXJ1bGVzLmFkZHJlc3MgfHwgdGhpcyAhPSAnJ0gAEpgCCgR1dWlkGBYgASgIQocCwkiDAgqlAQoLc3RyaW5nLnV1aWQSGnZhbHVlIG11c3QgYmUgYSB2YWxpZCBVVUlEGnohcnVsZXMudXVpZCB8fCB0aGlzID09ICcnIHx8IHRoaXMubWF0Y2hlcygnXlswLTlhLWZBLUZdezh9LVswLTlhLWZBLUZdezR9LVswLTlhLWZBLUZdezR9LVswLTlhLWZBLUZdezR9LVswLTlhLWZBLUZdezEyfSQnKQpZChFzdHJpbmcudXVpZF9lbXB0eRIpdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIFVVSUQaGSFydWxlcy51dWlkIHx8IHRoaXMgIT0gJydIABLwAQoFdHV1aWQYISABKAhC3gHCSNoBCnMKDHN0cmluZy50dXVpZBIidmFsdWUgbXVzdCBiZSBhIHZhbGlkIHRyaW1tZWQgVVVJRBo/IXJ1bGVzLnR1dWlkIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5tYXRjaGVzKCdeWzAtOWEtZkEtRl17MzJ9JCcpCmMKEnN0cmluZy50dXVpZF9lbXB0eRIxdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIHRyaW1tZWQgVVVJRBoaIXJ1bGVzLnR1dWlkIHx8IHRoaXMgIT0gJydIABKWAgoRaXBfd2l0aF9wcmVmaXhsZW4YGiABKAhC+AHCSPQBCngKGHN0cmluZy5pcF93aXRoX3ByZWZpeGxlbhIfdmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQIHByZWZpeBo7IXJ1bGVzLmlwX3dpdGhfcHJlZml4bGVuIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwUHJlZml4KCkKeAoec3RyaW5nLmlwX3dpdGhfcHJlZml4bGVuX2VtcHR5Ei52YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVAgcHJlZml4GiYhcnVsZXMuaXBfd2l0aF9wcmVmaXhsZW4gfHwgdGhpcyAhPSAnJ0gAEs8CChNpcHY0X3dpdGhfcHJlZml4bGVuGBsgASgIQq8CwkirAgqTAQoac3RyaW5nLmlwdjRfd2l0aF9wcmVmaXhsZW4SNXZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY0IGFkZHJlc3Mgd2l0aCBwcmVmaXggbGVuZ3RoGj4hcnVsZXMuaXB2NF93aXRoX3ByZWZpeGxlbiB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcFByZWZpeCg0KQqSAQogc3RyaW5nLmlwdjRfd2l0aF9wcmVmaXhsZW5fZW1wdHkSRHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY0IGFkZHJlc3Mgd2l0aCBwcmVmaXggbGVuZ3RoGighcnVsZXMuaXB2NF93aXRoX3ByZWZpeGxlbiB8fCB0aGlzICE9ICcnSAASzwIKE2lwdjZfd2l0aF9wcmVmaXhsZW4YHCABKAhCrwLCSKsCCpMBChpzdHJpbmcuaXB2Nl93aXRoX3ByZWZpeGxlbhI1dmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQdjYgYWRkcmVzcyB3aXRoIHByZWZpeCBsZW5ndGgaPiFydWxlcy5pcHY2X3dpdGhfcHJlZml4bGVuIHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwUHJlZml4KDYpCpIBCiBzdHJpbmcuaXB2Nl93aXRoX3ByZWZpeGxlbl9lbXB0eRJEdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQdjYgYWRkcmVzcyB3aXRoIHByZWZpeCBsZW5ndGgaKCFydWxlcy5pcHY2X3dpdGhfcHJlZml4bGVuIHx8IHRoaXMgIT0gJydIABLyAQoJaXBfcHJlZml4GB0gASgIQtwBwkjYAQpsChBzdHJpbmcuaXBfcHJlZml4Eh92YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSVAgcHJlZml4GjchcnVsZXMuaXBfcHJlZml4IHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0lwUHJlZml4KHRydWUpCmgKFnN0cmluZy5pcF9wcmVmaXhfZW1wdHkSLnZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUCBwcmVmaXgaHiFydWxlcy5pcF9wcmVmaXggfHwgdGhpcyAhPSAnJ0gAEoMCCgtpcHY0X3ByZWZpeBgeIAEoCELrAcJI5wEKdQoSc3RyaW5nLmlwdjRfcHJlZml4EiF2YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSVB2NCBwcmVmaXgaPCFydWxlcy5pcHY0X3ByZWZpeCB8fCB0aGlzID09ICcnIHx8IHRoaXMuaXNJcFByZWZpeCg0LCB0cnVlKQpuChhzdHJpbmcuaXB2NF9wcmVmaXhfZW1wdHkSMHZhbHVlIGlzIGVtcHR5LCB3aGljaCBpcyBub3QgYSB2YWxpZCBJUHY0IHByZWZpeBogIXJ1bGVzLmlwdjRfcHJlZml4IHx8IHRoaXMgIT0gJydIABKDAgoLaXB2Nl9wcmVmaXgYHyABKAhC6wHCSOcBCnUKEnN0cmluZy5pcHY2X3ByZWZpeBIhdmFsdWUgbXVzdCBiZSBhIHZhbGlkIElQdjYgcHJlZml4GjwhcnVsZXMuaXB2Nl9wcmVmaXggfHwgdGhpcyA9PSAnJyB8fCB0aGlzLmlzSXBQcmVmaXgoNiwgdHJ1ZSkKbgoYc3RyaW5nLmlwdjZfcHJlZml4X2VtcHR5EjB2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NiBwcmVmaXgaICFydWxlcy5pcHY2X3ByZWZpeCB8fCB0aGlzICE9ICcnSAAStQIKDWhvc3RfYW5kX3BvcnQYICABKAhCmwLCSJcCCpkBChRzdHJpbmcuaG9zdF9hbmRfcG9ydBJBdmFsdWUgbXVzdCBiZSBhIHZhbGlkIGhvc3QgKGhvc3RuYW1lIG9yIElQIGFkZHJlc3MpIGFuZCBwb3J0IHBhaXIaPiFydWxlcy5ob3N0X2FuZF9wb3J0IHx8IHRoaXMgPT0gJycgfHwgdGhpcy5pc0hvc3RBbmRQb3J0KHRydWUpCnkKGnN0cmluZy5ob3N0X2FuZF9wb3J0X2VtcHR5Ejd2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgaG9zdCBhbmQgcG9ydCBwYWlyGiIhcnVsZXMuaG9zdF9hbmRfcG9ydCB8fCB0aGlzICE9ICcnSAASqAUKEHdlbGxfa25vd25fcmVnZXgYGCABKA4yGC5idWYudmFsaWRhdGUuS25vd25SZWdleELxBMJI7QQK8AEKI3N0cmluZy53ZWxsX2tub3duX3JlZ2V4LmhlYWRlcl9uYW1lEiZ2YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSFRUUCBoZWFkZXIgbmFtZRqgAXJ1bGVzLndlbGxfa25vd25fcmVnZXggIT0gMSB8fCB0aGlzID09ICcnIHx8IHRoaXMubWF0Y2hlcyghaGFzKHJ1bGVzLnN0cmljdCkgfHwgcnVsZXMuc3RyaWN0ID8nXjo/WzAtOWEtekEtWiEjJCUmXCcqKy0uXl98flx4NjBdKyQnIDonXlteXHUwMDAwXHUwMDBBXHUwMDBEXSskJykKjQEKKXN0cmluZy53ZWxsX2tub3duX3JlZ2V4LmhlYWRlcl9uYW1lX2VtcHR5EjV2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSFRUUCBoZWFkZXIgbmFtZRopcnVsZXMud2VsbF9rbm93bl9yZWdleCAhPSAxIHx8IHRoaXMgIT0gJycK5wEKJHN0cmluZy53ZWxsX2tub3duX3JlZ2V4LmhlYWRlcl92YWx1ZRIndmFsdWUgbXVzdCBiZSBhIHZhbGlkIEhUVFAgaGVhZGVyIHZhbHVlGpUBcnVsZXMud2VsbF9rbm93bl9yZWdleCAhPSAyIHx8IHRoaXMubWF0Y2hlcyghaGFzKHJ1bGVzLnN0cmljdCkgfHwgcnVsZXMuc3RyaWN0ID8nXlteXHUwMDAwLVx1MDAwOFx1MDAwQS1cdTAwMUZcdTAwN0ZdKiQnIDonXlteXHUwMDAwXHUwMDBBXHUwMDBEXSokJylIABIOCgZzdHJpY3QYGSABKAgSLAoHZXhhbXBsZRgiIAMoCUIbwkgYChYKDnN0cmluZy5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCDAoKd2VsbF9rbm93biLqEAoKQnl0ZXNSdWxlcxKAAQoFY29uc3QYASABKAxCccJIbgpsCgtieXRlcy5jb25zdBpddGhpcyAhPSBnZXRGaWVsZChydWxlcywgJ2NvbnN0JykgPyAndmFsdWUgbXVzdCBiZSAleCcuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2NvbnN0JyldKSA6ICcnEngKA2xlbhgNIAEoBEJrwkhoCmYKCWJ5dGVzLmxlbhpZdWludCh0aGlzLnNpemUoKSkgIT0gcnVsZXMubGVuID8gJ3ZhbHVlIGxlbmd0aCBtdXN0IGJlICVzIGJ5dGVzJy5mb3JtYXQoW3J1bGVzLmxlbl0pIDogJycSkAEKB21pbl9sZW4YAiABKARCf8JIfAp6Cg1ieXRlcy5taW5fbGVuGml1aW50KHRoaXMuc2l6ZSgpKSA8IHJ1bGVzLm1pbl9sZW4gPyAndmFsdWUgbGVuZ3RoIG11c3QgYmUgYXQgbGVhc3QgJXMgYnl0ZXMnLmZvcm1hdChbcnVsZXMubWluX2xlbl0pIDogJycSiAEKB21heF9sZW4YAyABKARCd8JIdApyCg1ieXRlcy5tYXhfbGVuGmF1aW50KHRoaXMuc2l6ZSgpKSA+IHJ1bGVzLm1heF9sZW4gPyAndmFsdWUgbXVzdCBiZSBhdCBtb3N0ICVzIGJ5dGVzJy5mb3JtYXQoW3J1bGVzLm1heF9sZW5dKSA6ICcnEpABCgdwYXR0ZXJuGAQgASgJQn/CSHwKegoNYnl0ZXMucGF0dGVybhppIXN0cmluZyh0aGlzKS5tYXRjaGVzKHJ1bGVzLnBhdHRlcm4pID8gJ3ZhbHVlIG11c3QgbWF0Y2ggcmVnZXggcGF0dGVybiBgJXNgJy5mb3JtYXQoW3J1bGVzLnBhdHRlcm5dKSA6ICcnEoEBCgZwcmVmaXgYBSABKAxCccJIbgpsCgxieXRlcy5wcmVmaXgaXCF0aGlzLnN0YXJ0c1dpdGgocnVsZXMucHJlZml4KSA/ICd2YWx1ZSBkb2VzIG5vdCBoYXZlIHByZWZpeCAleCcuZm9ybWF0KFtydWxlcy5wcmVmaXhdKSA6ICcnEn8KBnN1ZmZpeBgGIAEoDEJvwkhsCmoKDGJ5dGVzLnN1ZmZpeBpaIXRoaXMuZW5kc1dpdGgocnVsZXMuc3VmZml4KSA/ICd2YWx1ZSBkb2VzIG5vdCBoYXZlIHN1ZmZpeCAleCcuZm9ybWF0KFtydWxlcy5zdWZmaXhdKSA6ICcnEoMBCghjb250YWlucxgHIAEoDEJxwkhuCmwKDmJ5dGVzLmNvbnRhaW5zGlohdGhpcy5jb250YWlucyhydWxlcy5jb250YWlucykgPyAndmFsdWUgZG9lcyBub3QgY29udGFpbiAleCcuZm9ybWF0KFtydWxlcy5jb250YWluc10pIDogJycSpwEKAmluGAggAygMQpoBwkiWAQqTAQoIYnl0ZXMuaW4ahgFnZXRGaWVsZChydWxlcywgJ2luJykuc2l6ZSgpID4gMCAmJiAhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ2CgZub3RfaW4YCSADKAxCZsJIYwphCgxieXRlcy5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxLrAQoCaXAYCiABKAhC3AHCSNgBCnQKCGJ5dGVzLmlwEiB2YWx1ZSBtdXN0IGJlIGEgdmFsaWQgSVAgYWRkcmVzcxpGIXJ1bGVzLmlwIHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gNCB8fCB0aGlzLnNpemUoKSA9PSAxNgpgCg5ieXRlcy5pcF9lbXB0eRIvdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQIGFkZHJlc3MaHSFydWxlcy5pcCB8fCB0aGlzLnNpemUoKSAhPSAwSAAS5AEKBGlwdjQYCyABKAhC0wHCSM8BCmUKCmJ5dGVzLmlwdjQSInZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY0IGFkZHJlc3MaMyFydWxlcy5pcHY0IHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gNApmChBieXRlcy5pcHY0X2VtcHR5EjF2YWx1ZSBpcyBlbXB0eSwgd2hpY2ggaXMgbm90IGEgdmFsaWQgSVB2NCBhZGRyZXNzGh8hcnVsZXMuaXB2NCB8fCB0aGlzLnNpemUoKSAhPSAwSAAS5QEKBGlwdjYYDCABKAhC1AHCSNABCmYKCmJ5dGVzLmlwdjYSInZhbHVlIG11c3QgYmUgYSB2YWxpZCBJUHY2IGFkZHJlc3MaNCFydWxlcy5pcHY2IHx8IHRoaXMuc2l6ZSgpID09IDAgfHwgdGhpcy5zaXplKCkgPT0gMTYKZgoQYnl0ZXMuaXB2Nl9lbXB0eRIxdmFsdWUgaXMgZW1wdHksIHdoaWNoIGlzIG5vdCBhIHZhbGlkIElQdjYgYWRkcmVzcxofIXJ1bGVzLmlwdjYgfHwgdGhpcy5zaXplKCkgIT0gMEgAEisKB2V4YW1wbGUYDiADKAxCGsJIFwoVCg1ieXRlcy5leGFtcGxlGgR0cnVlKgkI6AcQgICAgAJCDAoKd2VsbF9rbm93biLUAwoJRW51bVJ1bGVzEoIBCgVjb25zdBgBIAEoBUJzwkhwCm4KCmVudW0uY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxIUCgxkZWZpbmVkX29ubHkYAiABKAgSfgoCaW4YAyADKAVCcsJIbwptCgdlbnVtLmluGmIhKHRoaXMgaW4gZ2V0RmllbGQocnVsZXMsICdpbicpKSA/ICd2YWx1ZSBtdXN0IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdpbicpXSkgOiAnJxJ1CgZub3RfaW4YBCADKAVCZcJIYgpgCgtlbnVtLm5vdF9pbhpRdGhpcyBpbiBydWxlcy5ub3RfaW4gPyAndmFsdWUgbXVzdCBub3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtydWxlcy5ub3RfaW5dKSA6ICcnEioKB2V4YW1wbGUYBSADKAVCGcJIFgoUCgxlbnVtLmV4YW1wbGUaBHRydWUqCQjoBxCAgICAAiL7AwoNUmVwZWF0ZWRSdWxlcxKeAQoJbWluX2l0ZW1zGAEgASgEQooBwkiGAQqDAQoScmVwZWF0ZWQubWluX2l0ZW1zGm11aW50KHRoaXMuc2l6ZSgpKSA8IHJ1bGVzLm1pbl9pdGVtcyA/ICd2YWx1ZSBtdXN0IGNvbnRhaW4gYXQgbGVhc3QgJWQgaXRlbShzKScuZm9ybWF0KFtydWxlcy5taW5faXRlbXNdKSA6ICcnEqIBCgltYXhfaXRlbXMYAiABKARCjgHCSIoBCocBChJyZXBlYXRlZC5tYXhfaXRlbXMacXVpbnQodGhpcy5zaXplKCkpID4gcnVsZXMubWF4X2l0ZW1zID8gJ3ZhbHVlIG11c3QgY29udGFpbiBubyBtb3JlIHRoYW4gJXMgaXRlbShzKScuZm9ybWF0KFtydWxlcy5tYXhfaXRlbXNdKSA6ICcnEnAKBnVuaXF1ZRgDIAEoCEJgwkhdClsKD3JlcGVhdGVkLnVuaXF1ZRIocmVwZWF0ZWQgdmFsdWUgbXVzdCBjb250YWluIHVuaXF1ZSBpdGVtcxoeIXJ1bGVzLnVuaXF1ZSB8fCB0aGlzLnVuaXF1ZSgpEicKBWl0ZW1zGAQgASgLMhguYnVmLnZhbGlkYXRlLkZpZWxkUnVsZXMqCQjoBxCAgICAAiKKAwoITWFwUnVsZXMSjwEKCW1pbl9wYWlycxgBIAEoBEJ8wkh5CncKDW1hcC5taW5fcGFpcnMaZnVpbnQodGhpcy5zaXplKCkpIDwgcnVsZXMubWluX3BhaXJzID8gJ21hcCBtdXN0IGJlIGF0IGxlYXN0ICVkIGVudHJpZXMnLmZvcm1hdChbcnVsZXMubWluX3BhaXJzXSkgOiAnJxKOAQoJbWF4X3BhaXJzGAIgASgEQnvCSHgKdgoNbWFwLm1heF9wYWlycxpldWludCh0aGlzLnNpemUoKSkgPiBydWxlcy5tYXhfcGFpcnMgPyAnbWFwIG11c3QgYmUgYXQgbW9zdCAlZCBlbnRyaWVzJy5mb3JtYXQoW3J1bGVzLm1heF9wYWlyc10pIDogJycSJgoEa2V5cxgEIAEoCzIYLmJ1Zi52YWxpZGF0ZS5GaWVsZFJ1bGVzEigKBnZhbHVlcxgFIAEoCzIYLmJ1Zi52YWxpZGF0ZS5GaWVsZFJ1bGVzKgkI6AcQgICAgAIiJgoIQW55UnVsZXMSCgoCaW4YAiADKAkSDgoGbm90X2luGAMgAygJIpkXCg1EdXJhdGlvblJ1bGVzEqEBCgVjb25zdBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkJ3wkh0CnIKDmR1cmF0aW9uLmNvbnN0GmB0aGlzICE9IGdldEZpZWxkKHJ1bGVzLCAnY29uc3QnKSA/ICd2YWx1ZSBtdXN0IGVxdWFsICVzJy5mb3JtYXQoW2dldEZpZWxkKHJ1bGVzLCAnY29uc3QnKV0pIDogJycSqAEKAmx0GAMgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQn/CSHwKegoLZHVyYXRpb24ubHQaayFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID49IHJ1bGVzLmx0PyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMubHRdKSA6ICcnSAASugEKA2x0ZRgEIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkKPAcJIiwEKiAEKDGR1cmF0aW9uLmx0ZRp4IWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPiBydWxlcy5sdGU/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5sdGVdKSA6ICcnSAASwQcKAmd0GAUgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQpcHwkiTBwp9CgtkdXJhdGlvbi5ndBpuIWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPD0gcnVsZXMuZ3Q/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndF0pIDogJycKtgEKDmR1cmF0aW9uLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq+AQoYZHVyYXRpb24uZ3RfbHRfZXhjbHVzaXZlGqEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ICYmIChydWxlcy5sdCA8PSB0aGlzICYmIHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgb3IgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0LCBydWxlcy5sdF0pIDogJycKxgEKD2R1cmF0aW9uLmd0X2x0ZRqyAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndCAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0ZV0pIDogJycKzgEKGWR1cmF0aW9uLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEo0ICgNndGUYBiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25C4gfCSN4HCosBCgxkdXJhdGlvbi5ndGUaeyFoYXMocnVsZXMubHQpICYmICFoYXMocnVsZXMubHRlKSAmJiB0aGlzIDwgcnVsZXMuZ3RlPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlXSkgOiAnJwrFAQoPZHVyYXRpb24uZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCs0BChlkdXJhdGlvbi5ndGVfbHRfZXhjbHVzaXZlGq8BaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA8IHJ1bGVzLmd0ZSAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndGUsIHJ1bGVzLmx0XSkgOiAnJwrVAQoQZHVyYXRpb24uZ3RlX2x0ZRrAAWhhcyhydWxlcy5sdGUpICYmIHJ1bGVzLmx0ZSA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPiBydWxlcy5sdGUgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuIG9yIGVxdWFsIHRvICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRlXSkgOiAnJwrdAQoaZHVyYXRpb24uZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESnQEKAmluGAcgAygLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQnbCSHMKcQoLZHVyYXRpb24uaW4aYiEodGhpcyBpbiBnZXRGaWVsZChydWxlcywgJ2luJykpID8gJ3ZhbHVlIG11c3QgYmUgaW4gbGlzdCAlcycuZm9ybWF0KFtnZXRGaWVsZChydWxlcywgJ2luJyldKSA6ICcnEpQBCgZub3RfaW4YCCADKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CacJIZgpkCg9kdXJhdGlvbi5ub3RfaW4aUXRoaXMgaW4gcnVsZXMubm90X2luID8gJ3ZhbHVlIG11c3Qgbm90IGJlIGluIGxpc3QgJXMnLmZvcm1hdChbcnVsZXMubm90X2luXSkgOiAnJxJJCgdleGFtcGxlGAkgAygLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQh3CSBoKGAoQZHVyYXRpb24uZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4ikhgKDlRpbWVzdGFtcFJ1bGVzEqMBCgVjb25zdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCeMJIdQpzCg90aW1lc3RhbXAuY29uc3QaYHRoaXMgIT0gZ2V0RmllbGQocnVsZXMsICdjb25zdCcpID8gJ3ZhbHVlIG11c3QgZXF1YWwgJXMnLmZvcm1hdChbZ2V0RmllbGQocnVsZXMsICdjb25zdCcpXSkgOiAnJxKrAQoCbHQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQoABwkh9CnsKDHRpbWVzdGFtcC5sdBprIWhhcyhydWxlcy5ndGUpICYmICFoYXMocnVsZXMuZ3QpICYmIHRoaXMgPj0gcnVsZXMubHQ/ICd2YWx1ZSBtdXN0IGJlIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5sdF0pIDogJydIABK8AQoDbHRlGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEKQAcJIjAEKiQEKDXRpbWVzdGFtcC5sdGUaeCFoYXMocnVsZXMuZ3RlKSAmJiAhaGFzKHJ1bGVzLmd0KSAmJiB0aGlzID4gcnVsZXMubHRlPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMubHRlXSkgOiAnJ0gAEmwKBmx0X25vdxgHIAEoCEJawkhXClUKEHRpbWVzdGFtcC5sdF9ub3caQShydWxlcy5sdF9ub3cgJiYgdGhpcyA+IG5vdykgPyAndmFsdWUgbXVzdCBiZSBsZXNzIHRoYW4gbm93JyA6ICcnSAASxwcKAmd0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEKcB8JImAcKfgoMdGltZXN0YW1wLmd0Gm4haGFzKHJ1bGVzLmx0KSAmJiAhaGFzKHJ1bGVzLmx0ZSkgJiYgdGhpcyA8PSBydWxlcy5ndD8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0XSkgOiAnJwq3AQoPdGltZXN0YW1wLmd0X2x0GqMBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndCAmJiAodGhpcyA+PSBydWxlcy5sdCB8fCB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIGFuZCBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3QsIHJ1bGVzLmx0XSkgOiAnJwq/AQoZdGltZXN0YW1wLmd0X2x0X2V4Y2x1c2l2ZRqhAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndCAmJiAocnVsZXMubHQgPD0gdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRdKSA6ICcnCscBChB0aW1lc3RhbXAuZ3RfbHRlGrIBaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ICYmICh0aGlzID4gcnVsZXMubHRlIHx8IHRoaXMgPD0gcnVsZXMuZ3QpPyAndmFsdWUgbXVzdCBiZSBncmVhdGVyIHRoYW4gJXMgYW5kIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJwrPAQoadGltZXN0YW1wLmd0X2x0ZV9leGNsdXNpdmUasAFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndCAmJiAocnVsZXMubHRlIDwgdGhpcyAmJiB0aGlzIDw9IHJ1bGVzLmd0KT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuICVzIG9yIGxlc3MgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndCwgcnVsZXMubHRlXSkgOiAnJ0gBEpMICgNndGUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQucHwkjjBwqMAQoNdGltZXN0YW1wLmd0ZRp7IWhhcyhydWxlcy5sdCkgJiYgIWhhcyhydWxlcy5sdGUpICYmIHRoaXMgPCBydWxlcy5ndGU/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcycuZm9ybWF0KFtydWxlcy5ndGVdKSA6ICcnCsYBChB0aW1lc3RhbXAuZ3RlX2x0GrEBaGFzKHJ1bGVzLmx0KSAmJiBydWxlcy5sdCA+PSBydWxlcy5ndGUgJiYgKHRoaXMgPj0gcnVsZXMubHQgfHwgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBhbmQgbGVzcyB0aGFuICVzJy5mb3JtYXQoW3J1bGVzLmd0ZSwgcnVsZXMubHRdKSA6ICcnCs4BChp0aW1lc3RhbXAuZ3RlX2x0X2V4Y2x1c2l2ZRqvAWhhcyhydWxlcy5sdCkgJiYgcnVsZXMubHQgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0IDw9IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdF0pIDogJycK1gEKEXRpbWVzdGFtcC5ndGVfbHRlGsABaGFzKHJ1bGVzLmx0ZSkgJiYgcnVsZXMubHRlID49IHJ1bGVzLmd0ZSAmJiAodGhpcyA+IHJ1bGVzLmx0ZSB8fCB0aGlzIDwgcnVsZXMuZ3RlKT8gJ3ZhbHVlIG11c3QgYmUgZ3JlYXRlciB0aGFuIG9yIGVxdWFsIHRvICVzIGFuZCBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnCt4BCht0aW1lc3RhbXAuZ3RlX2x0ZV9leGNsdXNpdmUavgFoYXMocnVsZXMubHRlKSAmJiBydWxlcy5sdGUgPCBydWxlcy5ndGUgJiYgKHJ1bGVzLmx0ZSA8IHRoaXMgJiYgdGhpcyA8IHJ1bGVzLmd0ZSk/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBvciBlcXVhbCB0byAlcyBvciBsZXNzIHRoYW4gb3IgZXF1YWwgdG8gJXMnLmZvcm1hdChbcnVsZXMuZ3RlLCBydWxlcy5sdGVdKSA6ICcnSAESbwoGZ3Rfbm93GAggASgIQl3CSFoKWAoQdGltZXN0YW1wLmd0X25vdxpEKHJ1bGVzLmd0X25vdyAmJiB0aGlzIDwgbm93KSA/ICd2YWx1ZSBtdXN0IGJlIGdyZWF0ZXIgdGhhbiBub3cnIDogJydIARK4AQoGd2l0aGluGAkgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQowBwkiIAQqFAQoQdGltZXN0YW1wLndpdGhpbhpxdGhpcyA8IG5vdy1ydWxlcy53aXRoaW4gfHwgdGhpcyA+IG5vdytydWxlcy53aXRoaW4gPyAndmFsdWUgbXVzdCBiZSB3aXRoaW4gJXMgb2Ygbm93Jy5mb3JtYXQoW3J1bGVzLndpdGhpbl0pIDogJycSSwoHZXhhbXBsZRgKIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCHsJIGwoZChF0aW1lc3RhbXAuZXhhbXBsZRoEdHJ1ZSoJCOgHEICAgIACQgsKCWxlc3NfdGhhbkIOCgxncmVhdGVyX3RoYW4iOQoKVmlvbGF0aW9ucxIrCgp2aW9sYXRpb25zGAEgAygLMhcuYnVmLnZhbGlkYXRlLlZpb2xhdGlvbiKfAQoJVmlvbGF0aW9uEiYKBWZpZWxkGAUgASgLMhcuYnVmLnZhbGlkYXRlLkZpZWxkUGF0aBIlCgRydWxlGAYgASgLMhcuYnVmLnZhbGlkYXRlLkZpZWxkUGF0aBIPCgdydWxlX2lkGAIgASgJEg8KB21lc3NhZ2UYAyABKAkSDwoHZm9yX2tleRgEIAEoCEoECAEQAlIKZmllbGRfcGF0aCI9CglGaWVsZFBhdGgSMAoIZWxlbWVudHMYASADKAsyHi5idWYudmFsaWRhdGUuRmllbGRQYXRoRWxlbWVudCLpAgoQRmllbGRQYXRoRWxlbWVudBIUCgxmaWVsZF9udW1iZXIYASABKAUSEgoKZmllbGRfbmFtZRgCIAEoCRI+CgpmaWVsZF90eXBlGAMgASgOMiouZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvLlR5cGUSPAoIa2V5X3R5cGUYBCABKA4yKi5nb29nbGUucHJvdG9idWYuRmllbGREZXNjcmlwdG9yUHJvdG8uVHlwZRI+Cgp2YWx1ZV90eXBlGAUgASgOMiouZ29vZ2xlLnByb3RvYnVmLkZpZWxkRGVzY3JpcHRvclByb3RvLlR5cGUSDwoFaW5kZXgYBiABKARIABISCghib29sX2tleRgHIAEoCEgAEhEKB2ludF9rZXkYCCABKANIABISCgh1aW50X2tleRgJIAEoBEgAEhQKCnN0cmluZ19rZXkYCiABKAlIAEILCglzdWJzY3JpcHQqhwEKBklnbm9yZRIWChJJR05PUkVfVU5TUEVDSUZJRUQQABIZChVJR05PUkVfSUZfVU5QT1BVTEFURUQQARIbChdJR05PUkVfSUZfREVGQVVMVF9WQUxVRRACEhEKDUlHTk9SRV9BTFdBWVMQAyoaSUdOT1JFX0VNUFRZSUdOT1JFX0RFRkFVTFQqbgoKS25vd25SZWdleBIbChdLTk9XTl9SRUdFWF9VTlNQRUNJRklFRBAAEiAKHEtOT1dOX1JFR0VYX0hUVFBfSEVBREVSX05BTUUQARIhCh1LTk9XTl9SRUdFWF9IVFRQX0hFQURFUl9WQUxVRRACOlYKB21lc3NhZ2USHy5nb29nbGUucHJvdG9idWYuTWVzc2FnZU9wdGlvbnMYhwkgASgLMhouYnVmLnZhbGlkYXRlLk1lc3NhZ2VSdWxlc1IHbWVzc2FnZTpOCgVvbmVvZhIdLmdvb2dsZS5wcm90b2J1Zi5PbmVvZk9wdGlvbnMYhwkgASgLMhguYnVmLnZhbGlkYXRlLk9uZW9mUnVsZXNSBW9uZW9mOk4KBWZpZWxkEh0uZ29vZ2xlLnByb3RvYnVmLkZpZWxkT3B0aW9ucxiHCSABKAsyGC5idWYudmFsaWRhdGUuRmllbGRSdWxlc1IFZmllbGQ6XQoKcHJlZGVmaW5lZBIdLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE9wdGlvbnMYiAkgASgLMh0uYnVmLnZhbGlkYXRlLlByZWRlZmluZWRSdWxlc1IKcHJlZGVmaW5lZEJuChJidWlsZC5idWYudmFsaWRhdGVCDVZhbGlkYXRlUHJvdG9QAVpHYnVmLmJ1aWxkL2dlbi9nby9idWZidWlsZC9wcm90b3ZhbGlkYXRlL3Byb3RvY29sYnVmZmVycy9nby9idWYvdmFsaWRhdGU", + [file_google_protobuf_descriptor, file_google_protobuf_duration, file_google_protobuf_timestamp], + ); + +/** + * `Rule` represents a validation rule written in the Common Expression + * Language (CEL) syntax. Each Rule includes a unique identifier, an + * optional error message, and the CEL expression to evaluate. For more + * information on CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * ```proto + * message Foo { + * option (buf.validate.message).cel = { + * id: "foo.bar" + * message: "bar must be greater than 0" + * expression: "this.bar > 0" + * }; + * int32 bar = 1; + * } + * ``` + * + * @generated from message buf.validate.Rule + */ +export type Rule = Message<"buf.validate.Rule"> & { + /** + * `id` is a string that serves as a machine-readable name for this Rule. + * It should be unique within its scope, which could be either a message or a field. + * + * @generated from field: optional string id = 1; + */ + id: string; + + /** + * `message` is an optional field that provides a human-readable error message + * for this Rule when the CEL expression evaluates to false. If a + * non-empty message is provided, any strings resulting from the CEL + * expression evaluation are ignored. + * + * @generated from field: optional string message = 2; + */ + message: string; + + /** + * `expression` is the actual CEL expression that will be evaluated for + * validation. This string must resolve to either a boolean or a string + * value. If the expression evaluates to false or a non-empty string, the + * validation is considered failed, and the message is rejected. + * + * @generated from field: optional string expression = 3; + */ + expression: string; +}; + +/** + * Describes the message buf.validate.Rule. + * Use `create(RuleSchema)` to create a new message. + */ +export const RuleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 0); + +/** + * MessageRules represents validation rules that are applied to the entire message. + * It includes disabling options and a list of Rule messages representing Common Expression Language (CEL) validation rules. + * + * @generated from message buf.validate.MessageRules + */ +export type MessageRules = Message<"buf.validate.MessageRules"> & { + /** + * `disabled` is a boolean flag that, when set to true, nullifies any validation rules for this message. + * This includes any fields within the message that would otherwise support validation. + * + * ```proto + * message MyMessage { + * // validation will be bypassed for this message + * option (buf.validate.message).disabled = true; + * } + * ``` + * + * @generated from field: optional bool disabled = 1; + */ + disabled: boolean; + + /** + * `cel` is a repeated field of type Rule. Each Rule specifies a validation rule to be applied to this message. + * These rules are written in Common Expression Language (CEL) syntax. For more information on + * CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * + * ```proto + * message MyMessage { + * // The field `foo` must be greater than 42. + * option (buf.validate.message).cel = { + * id: "my_message.value", + * message: "value must be greater than 42", + * expression: "this.foo > 42", + * }; + * optional int32 foo = 1; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 3; + */ + cel: Rule[]; +}; + +/** + * Describes the message buf.validate.MessageRules. + * Use `create(MessageRulesSchema)` to create a new message. + */ +export const MessageRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 1); + +/** + * The `OneofRules` message type enables you to manage rules for + * oneof fields in your protobuf messages. + * + * @generated from message buf.validate.OneofRules + */ +export type OneofRules = Message<"buf.validate.OneofRules"> & { + /** + * If `required` is true, exactly one field of the oneof must be present. A + * validation error is returned if no fields in the oneof are present. The + * field itself may still be a default value; further rules + * should be placed on the fields themselves to ensure they are valid values, + * such as `min_len` or `gt`. + * + * ```proto + * message MyMessage { + * oneof value { + * // Either `a` or `b` must be set. If `a` is set, it must also be + * // non-empty; whereas if `b` is set, it can still be an empty string. + * option (buf.validate.oneof).required = true; + * string a = 1 [(buf.validate.field).string.min_len = 1]; + * string b = 2; + * } + * } + * ``` + * + * @generated from field: optional bool required = 1; + */ + required: boolean; +}; + +/** + * Describes the message buf.validate.OneofRules. + * Use `create(OneofRulesSchema)` to create a new message. + */ +export const OneofRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 2); + +/** + * FieldRules encapsulates the rules for each type of field. Depending on + * the field, the correct set should be used to ensure proper validations. + * + * @generated from message buf.validate.FieldRules + */ +export type FieldRules = Message<"buf.validate.FieldRules"> & { + /** + * `cel` is a repeated field used to represent a textual expression + * in the Common Expression Language (CEL) syntax. For more information on + * CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * ```proto + * message MyMessage { + * // The field `value` must be greater than 42. + * optional int32 value = 1 [(buf.validate.field).cel = { + * id: "my_message.value", + * message: "value must be greater than 42", + * expression: "this > 42", + * }]; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 23; + */ + cel: Rule[]; + + /** + * If `required` is true, the field must be populated. A populated field can be + * described as "serialized in the wire format," which includes: + * + * - the following "nullable" fields must be explicitly set to be considered populated: + * - singular message fields (whose fields may be unpopulated / default values) + * - member fields of a oneof (may be their default value) + * - proto3 optional fields (may be their default value) + * - proto2 scalar fields (both optional and required) + * - proto3 scalar fields must be non-zero to be considered populated + * - repeated and map fields must be non-empty to be considered populated + * + * ```proto + * message MyMessage { + * // The field `value` must be set to a non-null value. + * optional MyOtherMessage value = 1 [(buf.validate.field).required = true]; + * } + * ``` + * + * @generated from field: optional bool required = 25; + */ + required: boolean; + + /** + * Skip validation on the field if its value matches the specified criteria. + * See Ignore enum for details. + * + * ```proto + * message UpdateRequest { + * // The uri rule only applies if the field is populated and not an empty + * // string. + * optional string url = 1 [ + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE, + * (buf.validate.field).string.uri = true, + * ]; + * } + * ``` + * + * @generated from field: optional buf.validate.Ignore ignore = 27; + */ + ignore: Ignore; + + /** + * @generated from oneof buf.validate.FieldRules.type + */ + type: + | { + /** + * Scalar Field Types + * + * @generated from field: buf.validate.FloatRules float = 1; + */ + value: FloatRules; + case: "float"; + } + | { + /** + * @generated from field: buf.validate.DoubleRules double = 2; + */ + value: DoubleRules; + case: "double"; + } + | { + /** + * @generated from field: buf.validate.Int32Rules int32 = 3; + */ + value: Int32Rules; + case: "int32"; + } + | { + /** + * @generated from field: buf.validate.Int64Rules int64 = 4; + */ + value: Int64Rules; + case: "int64"; + } + | { + /** + * @generated from field: buf.validate.UInt32Rules uint32 = 5; + */ + value: UInt32Rules; + case: "uint32"; + } + | { + /** + * @generated from field: buf.validate.UInt64Rules uint64 = 6; + */ + value: UInt64Rules; + case: "uint64"; + } + | { + /** + * @generated from field: buf.validate.SInt32Rules sint32 = 7; + */ + value: SInt32Rules; + case: "sint32"; + } + | { + /** + * @generated from field: buf.validate.SInt64Rules sint64 = 8; + */ + value: SInt64Rules; + case: "sint64"; + } + | { + /** + * @generated from field: buf.validate.Fixed32Rules fixed32 = 9; + */ + value: Fixed32Rules; + case: "fixed32"; + } + | { + /** + * @generated from field: buf.validate.Fixed64Rules fixed64 = 10; + */ + value: Fixed64Rules; + case: "fixed64"; + } + | { + /** + * @generated from field: buf.validate.SFixed32Rules sfixed32 = 11; + */ + value: SFixed32Rules; + case: "sfixed32"; + } + | { + /** + * @generated from field: buf.validate.SFixed64Rules sfixed64 = 12; + */ + value: SFixed64Rules; + case: "sfixed64"; + } + | { + /** + * @generated from field: buf.validate.BoolRules bool = 13; + */ + value: BoolRules; + case: "bool"; + } + | { + /** + * @generated from field: buf.validate.StringRules string = 14; + */ + value: StringRules; + case: "string"; + } + | { + /** + * @generated from field: buf.validate.BytesRules bytes = 15; + */ + value: BytesRules; + case: "bytes"; + } + | { + /** + * Complex Field Types + * + * @generated from field: buf.validate.EnumRules enum = 16; + */ + value: EnumRules; + case: "enum"; + } + | { + /** + * @generated from field: buf.validate.RepeatedRules repeated = 18; + */ + value: RepeatedRules; + case: "repeated"; + } + | { + /** + * @generated from field: buf.validate.MapRules map = 19; + */ + value: MapRules; + case: "map"; + } + | { + /** + * Well-Known Field Types + * + * @generated from field: buf.validate.AnyRules any = 20; + */ + value: AnyRules; + case: "any"; + } + | { + /** + * @generated from field: buf.validate.DurationRules duration = 21; + */ + value: DurationRules; + case: "duration"; + } + | { + /** + * @generated from field: buf.validate.TimestampRules timestamp = 22; + */ + value: TimestampRules; + case: "timestamp"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message buf.validate.FieldRules. + * Use `create(FieldRulesSchema)` to create a new message. + */ +export const FieldRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 3); + +/** + * PredefinedRules are custom rules that can be re-used with + * multiple fields. + * + * @generated from message buf.validate.PredefinedRules + */ +export type PredefinedRules = Message<"buf.validate.PredefinedRules"> & { + /** + * `cel` is a repeated field used to represent a textual expression + * in the Common Expression Language (CEL) syntax. For more information on + * CEL, [see our documentation](https://github.com/bufbuild/protovalidate/blob/main/docs/cel.md). + * + * ```proto + * message MyMessage { + * // The field `value` must be greater than 42. + * optional int32 value = 1 [(buf.validate.predefined).cel = { + * id: "my_message.value", + * message: "value must be greater than 42", + * expression: "this > 42", + * }]; + * } + * ``` + * + * @generated from field: repeated buf.validate.Rule cel = 1; + */ + cel: Rule[]; +}; + +/** + * Describes the message buf.validate.PredefinedRules. + * Use `create(PredefinedRulesSchema)` to create a new message. + */ +export const PredefinedRulesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 4); + +/** + * FloatRules describes the rules applied to `float` values. These + * rules may also be applied to the `google.protobuf.FloatValue` Well-Known-Type. + * + * @generated from message buf.validate.FloatRules + */ +export type FloatRules = Message<"buf.validate.FloatRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFloat { + * // value must equal 42.0 + * float value = 1 [(buf.validate.field).float.const = 42.0]; + * } + * ``` + * + * @generated from field: optional float const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.FloatRules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be less than 10.0 + * float value = 1 [(buf.validate.field).float.lt = 10.0]; + * } + * ``` + * + * @generated from field: float lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be less than or equal to 10.0 + * float value = 1 [(buf.validate.field).float.lte = 10.0]; + * } + * ``` + * + * @generated from field: float lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.FloatRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be greater than 5.0 [float.gt] + * float value = 1 [(buf.validate.field).float.gt = 5.0]; + * + * // value must be greater than 5 and less than 10.0 [float.gt_lt] + * float other_value = 2 [(buf.validate.field).float = { gt: 5.0, lt: 10.0 }]; + * + * // value must be greater than 10 or less than 5.0 [float.gt_lt_exclusive] + * float another_value = 3 [(buf.validate.field).float = { gt: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: float gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFloat { + * // value must be greater than or equal to 5.0 [float.gte] + * float value = 1 [(buf.validate.field).float.gte = 5.0]; + * + * // value must be greater than or equal to 5.0 and less than 10.0 [float.gte_lt] + * float other_value = 2 [(buf.validate.field).float = { gte: 5.0, lt: 10.0 }]; + * + * // value must be greater than or equal to 10.0 or less than 5.0 [float.gte_lt_exclusive] + * float another_value = 3 [(buf.validate.field).float = { gte: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: float gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MyFloat { + * // value must be in list [1.0, 2.0, 3.0] + * float value = 1 [(buf.validate.field).float = { in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated float in = 6; + */ + in: number[]; + + /** + * `in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFloat { + * // value must not be in list [1.0, 2.0, 3.0] + * float value = 1 [(buf.validate.field).float = { not_in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated float not_in = 7; + */ + notIn: number[]; + + /** + * `finite` requires the field value to be finite. If the field value is + * infinite or NaN, an error message is generated. + * + * @generated from field: optional bool finite = 8; + */ + finite: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFloat { + * float value = 1 [ + * (buf.validate.field).float.example = 1.0, + * (buf.validate.field).float.example = "Infinity" + * ]; + * } + * ``` + * + * @generated from field: repeated float example = 9; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.FloatRules. + * Use `create(FloatRulesSchema)` to create a new message. + */ +export const FloatRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 5); + +/** + * DoubleRules describes the rules applied to `double` values. These + * rules may also be applied to the `google.protobuf.DoubleValue` Well-Known-Type. + * + * @generated from message buf.validate.DoubleRules + */ +export type DoubleRules = Message<"buf.validate.DoubleRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyDouble { + * // value must equal 42.0 + * double value = 1 [(buf.validate.field).double.const = 42.0]; + * } + * ``` + * + * @generated from field: optional double const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.DoubleRules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyDouble { + * // value must be less than 10.0 + * double value = 1 [(buf.validate.field).double.lt = 10.0]; + * } + * ``` + * + * @generated from field: double lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified value + * (field <= value). If the field value is greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyDouble { + * // value must be less than or equal to 10.0 + * double value = 1 [(buf.validate.field).double.lte = 10.0]; + * } + * ``` + * + * @generated from field: double lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.DoubleRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or `lte`, + * the range is reversed, and the field value must be outside the specified + * range. If the field value doesn't meet the required conditions, an error + * message is generated. + * + * ```proto + * message MyDouble { + * // value must be greater than 5.0 [double.gt] + * double value = 1 [(buf.validate.field).double.gt = 5.0]; + * + * // value must be greater than 5 and less than 10.0 [double.gt_lt] + * double other_value = 2 [(buf.validate.field).double = { gt: 5.0, lt: 10.0 }]; + * + * // value must be greater than 10 or less than 5.0 [double.gt_lt_exclusive] + * double another_value = 3 [(buf.validate.field).double = { gt: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: double gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyDouble { + * // value must be greater than or equal to 5.0 [double.gte] + * double value = 1 [(buf.validate.field).double.gte = 5.0]; + * + * // value must be greater than or equal to 5.0 and less than 10.0 [double.gte_lt] + * double other_value = 2 [(buf.validate.field).double = { gte: 5.0, lt: 10.0 }]; + * + * // value must be greater than or equal to 10.0 or less than 5.0 [double.gte_lt_exclusive] + * double another_value = 3 [(buf.validate.field).double = { gte: 10.0, lt: 5.0 }]; + * } + * ``` + * + * @generated from field: double gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyDouble { + * // value must be in list [1.0, 2.0, 3.0] + * double value = 1 [(buf.validate.field).double = { in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated double in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyDouble { + * // value must not be in list [1.0, 2.0, 3.0] + * double value = 1 [(buf.validate.field).double = { not_in: [1.0, 2.0, 3.0] }]; + * } + * ``` + * + * @generated from field: repeated double not_in = 7; + */ + notIn: number[]; + + /** + * `finite` requires the field value to be finite. If the field value is + * infinite or NaN, an error message is generated. + * + * @generated from field: optional bool finite = 8; + */ + finite: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyDouble { + * double value = 1 [ + * (buf.validate.field).double.example = 1.0, + * (buf.validate.field).double.example = "Infinity" + * ]; + * } + * ``` + * + * @generated from field: repeated double example = 9; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.DoubleRules. + * Use `create(DoubleRulesSchema)` to create a new message. + */ +export const DoubleRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 6); + +/** + * Int32Rules describes the rules applied to `int32` values. These + * rules may also be applied to the `google.protobuf.Int32Value` Well-Known-Type. + * + * @generated from message buf.validate.Int32Rules + */ +export type Int32Rules = Message<"buf.validate.Int32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must equal 42 + * int32 value = 1 [(buf.validate.field).int32.const = 42]; + * } + * ``` + * + * @generated from field: optional int32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.Int32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be less than 10 + * int32 value = 1 [(buf.validate.field).int32.lt = 10]; + * } + * ``` + * + * @generated from field: int32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be less than or equal to 10 + * int32 value = 1 [(buf.validate.field).int32.lte = 10]; + * } + * ``` + * + * @generated from field: int32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Int32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be greater than 5 [int32.gt] + * int32 value = 1 [(buf.validate.field).int32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [int32.gt_lt] + * int32 other_value = 2 [(buf.validate.field).int32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [int32.gt_lt_exclusive] + * int32 another_value = 3 [(buf.validate.field).int32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified value + * (exclusive). If the value of `gte` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt32 { + * // value must be greater than or equal to 5 [int32.gte] + * int32 value = 1 [(buf.validate.field).int32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [int32.gte_lt] + * int32 other_value = 2 [(buf.validate.field).int32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [int32.gte_lt_exclusive] + * int32 another_value = 3 [(buf.validate.field).int32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyInt32 { + * // value must be in list [1, 2, 3] + * int32 value = 1 [(buf.validate.field).int32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error message + * is generated. + * + * ```proto + * message MyInt32 { + * // value must not be in list [1, 2, 3] + * int32 value = 1 [(buf.validate.field).int32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyInt32 { + * int32 value = 1 [ + * (buf.validate.field).int32.example = 1, + * (buf.validate.field).int32.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated int32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.Int32Rules. + * Use `create(Int32RulesSchema)` to create a new message. + */ +export const Int32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 7); + +/** + * Int64Rules describes the rules applied to `int64` values. These + * rules may also be applied to the `google.protobuf.Int64Value` Well-Known-Type. + * + * @generated from message buf.validate.Int64Rules + */ +export type Int64Rules = Message<"buf.validate.Int64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must equal 42 + * int64 value = 1 [(buf.validate.field).int64.const = 42]; + * } + * ``` + * + * @generated from field: optional int64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.Int64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be less than 10 + * int64 value = 1 [(buf.validate.field).int64.lt = 10]; + * } + * ``` + * + * @generated from field: int64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be less than or equal to 10 + * int64 value = 1 [(buf.validate.field).int64.lte = 10]; + * } + * ``` + * + * @generated from field: int64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Int64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be greater than 5 [int64.gt] + * int64 value = 1 [(buf.validate.field).int64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [int64.gt_lt] + * int64 other_value = 2 [(buf.validate.field).int64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [int64.gt_lt_exclusive] + * int64 another_value = 3 [(buf.validate.field).int64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyInt64 { + * // value must be greater than or equal to 5 [int64.gte] + * int64 value = 1 [(buf.validate.field).int64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [int64.gte_lt] + * int64 other_value = 2 [(buf.validate.field).int64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [int64.gte_lt_exclusive] + * int64 another_value = 3 [(buf.validate.field).int64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: int64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyInt64 { + * // value must be in list [1, 2, 3] + * int64 value = 1 [(buf.validate.field).int64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyInt64 { + * // value must not be in list [1, 2, 3] + * int64 value = 1 [(buf.validate.field).int64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated int64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyInt64 { + * int64 value = 1 [ + * (buf.validate.field).int64.example = 1, + * (buf.validate.field).int64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated int64 example = 9; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.Int64Rules. + * Use `create(Int64RulesSchema)` to create a new message. + */ +export const Int64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 8); + +/** + * UInt32Rules describes the rules applied to `uint32` values. These + * rules may also be applied to the `google.protobuf.UInt32Value` Well-Known-Type. + * + * @generated from message buf.validate.UInt32Rules + */ +export type UInt32Rules = Message<"buf.validate.UInt32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must equal 42 + * uint32 value = 1 [(buf.validate.field).uint32.const = 42]; + * } + * ``` + * + * @generated from field: optional uint32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.UInt32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be less than 10 + * uint32 value = 1 [(buf.validate.field).uint32.lt = 10]; + * } + * ``` + * + * @generated from field: uint32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be less than or equal to 10 + * uint32 value = 1 [(buf.validate.field).uint32.lte = 10]; + * } + * ``` + * + * @generated from field: uint32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.UInt32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be greater than 5 [uint32.gt] + * uint32 value = 1 [(buf.validate.field).uint32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [uint32.gt_lt] + * uint32 other_value = 2 [(buf.validate.field).uint32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [uint32.gt_lt_exclusive] + * uint32 another_value = 3 [(buf.validate.field).uint32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt32 { + * // value must be greater than or equal to 5 [uint32.gte] + * uint32 value = 1 [(buf.validate.field).uint32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [uint32.gte_lt] + * uint32 other_value = 2 [(buf.validate.field).uint32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [uint32.gte_lt_exclusive] + * uint32 another_value = 3 [(buf.validate.field).uint32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyUInt32 { + * // value must be in list [1, 2, 3] + * uint32 value = 1 [(buf.validate.field).uint32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyUInt32 { + * // value must not be in list [1, 2, 3] + * uint32 value = 1 [(buf.validate.field).uint32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyUInt32 { + * uint32 value = 1 [ + * (buf.validate.field).uint32.example = 1, + * (buf.validate.field).uint32.example = 10 + * ]; + * } + * ``` + * + * @generated from field: repeated uint32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.UInt32Rules. + * Use `create(UInt32RulesSchema)` to create a new message. + */ +export const UInt32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 9); + +/** + * UInt64Rules describes the rules applied to `uint64` values. These + * rules may also be applied to the `google.protobuf.UInt64Value` Well-Known-Type. + * + * @generated from message buf.validate.UInt64Rules + */ +export type UInt64Rules = Message<"buf.validate.UInt64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must equal 42 + * uint64 value = 1 [(buf.validate.field).uint64.const = 42]; + * } + * ``` + * + * @generated from field: optional uint64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.UInt64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be less than 10 + * uint64 value = 1 [(buf.validate.field).uint64.lt = 10]; + * } + * ``` + * + * @generated from field: uint64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be less than or equal to 10 + * uint64 value = 1 [(buf.validate.field).uint64.lte = 10]; + * } + * ``` + * + * @generated from field: uint64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.UInt64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be greater than 5 [uint64.gt] + * uint64 value = 1 [(buf.validate.field).uint64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [uint64.gt_lt] + * uint64 other_value = 2 [(buf.validate.field).uint64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [uint64.gt_lt_exclusive] + * uint64 another_value = 3 [(buf.validate.field).uint64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyUInt64 { + * // value must be greater than or equal to 5 [uint64.gte] + * uint64 value = 1 [(buf.validate.field).uint64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [uint64.gte_lt] + * uint64 other_value = 2 [(buf.validate.field).uint64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [uint64.gte_lt_exclusive] + * uint64 another_value = 3 [(buf.validate.field).uint64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: uint64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyUInt64 { + * // value must be in list [1, 2, 3] + * uint64 value = 1 [(buf.validate.field).uint64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyUInt64 { + * // value must not be in list [1, 2, 3] + * uint64 value = 1 [(buf.validate.field).uint64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated uint64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyUInt64 { + * uint64 value = 1 [ + * (buf.validate.field).uint64.example = 1, + * (buf.validate.field).uint64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated uint64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.UInt64Rules. + * Use `create(UInt64RulesSchema)` to create a new message. + */ +export const UInt64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 10); + +/** + * SInt32Rules describes the rules applied to `sint32` values. + * + * @generated from message buf.validate.SInt32Rules + */ +export type SInt32Rules = Message<"buf.validate.SInt32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must equal 42 + * sint32 value = 1 [(buf.validate.field).sint32.const = 42]; + * } + * ``` + * + * @generated from field: optional sint32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.SInt32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be less than 10 + * sint32 value = 1 [(buf.validate.field).sint32.lt = 10]; + * } + * ``` + * + * @generated from field: sint32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be less than or equal to 10 + * sint32 value = 1 [(buf.validate.field).sint32.lte = 10]; + * } + * ``` + * + * @generated from field: sint32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SInt32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be greater than 5 [sint32.gt] + * sint32 value = 1 [(buf.validate.field).sint32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sint32.gt_lt] + * sint32 other_value = 2 [(buf.validate.field).sint32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sint32.gt_lt_exclusive] + * sint32 another_value = 3 [(buf.validate.field).sint32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt32 { + * // value must be greater than or equal to 5 [sint32.gte] + * sint32 value = 1 [(buf.validate.field).sint32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sint32.gte_lt] + * sint32 other_value = 2 [(buf.validate.field).sint32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sint32.gte_lt_exclusive] + * sint32 another_value = 3 [(buf.validate.field).sint32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySInt32 { + * // value must be in list [1, 2, 3] + * sint32 value = 1 [(buf.validate.field).sint32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySInt32 { + * // value must not be in list [1, 2, 3] + * sint32 value = 1 [(buf.validate.field).sint32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySInt32 { + * sint32 value = 1 [ + * (buf.validate.field).sint32.example = 1, + * (buf.validate.field).sint32.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated sint32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.SInt32Rules. + * Use `create(SInt32RulesSchema)` to create a new message. + */ +export const SInt32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 11); + +/** + * SInt64Rules describes the rules applied to `sint64` values. + * + * @generated from message buf.validate.SInt64Rules + */ +export type SInt64Rules = Message<"buf.validate.SInt64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must equal 42 + * sint64 value = 1 [(buf.validate.field).sint64.const = 42]; + * } + * ``` + * + * @generated from field: optional sint64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.SInt64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field + * < value). If the field value is equal to or greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be less than 10 + * sint64 value = 1 [(buf.validate.field).sint64.lt = 10]; + * } + * ``` + * + * @generated from field: sint64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be less than or equal to 10 + * sint64 value = 1 [(buf.validate.field).sint64.lte = 10]; + * } + * ``` + * + * @generated from field: sint64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SInt64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be greater than 5 [sint64.gt] + * sint64 value = 1 [(buf.validate.field).sint64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sint64.gt_lt] + * sint64 other_value = 2 [(buf.validate.field).sint64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sint64.gt_lt_exclusive] + * sint64 another_value = 3 [(buf.validate.field).sint64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySInt64 { + * // value must be greater than or equal to 5 [sint64.gte] + * sint64 value = 1 [(buf.validate.field).sint64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sint64.gte_lt] + * sint64 other_value = 2 [(buf.validate.field).sint64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sint64.gte_lt_exclusive] + * sint64 another_value = 3 [(buf.validate.field).sint64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sint64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MySInt64 { + * // value must be in list [1, 2, 3] + * sint64 value = 1 [(buf.validate.field).sint64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySInt64 { + * // value must not be in list [1, 2, 3] + * sint64 value = 1 [(buf.validate.field).sint64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sint64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySInt64 { + * sint64 value = 1 [ + * (buf.validate.field).sint64.example = 1, + * (buf.validate.field).sint64.example = -10 + * ]; + * } + * ``` + * + * @generated from field: repeated sint64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.SInt64Rules. + * Use `create(SInt64RulesSchema)` to create a new message. + */ +export const SInt64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 12); + +/** + * Fixed32Rules describes the rules applied to `fixed32` values. + * + * @generated from message buf.validate.Fixed32Rules + */ +export type Fixed32Rules = Message<"buf.validate.Fixed32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must equal 42 + * fixed32 value = 1 [(buf.validate.field).fixed32.const = 42]; + * } + * ``` + * + * @generated from field: optional fixed32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.Fixed32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be less than 10 + * fixed32 value = 1 [(buf.validate.field).fixed32.lt = 10]; + * } + * ``` + * + * @generated from field: fixed32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be less than or equal to 10 + * fixed32 value = 1 [(buf.validate.field).fixed32.lte = 10]; + * } + * ``` + * + * @generated from field: fixed32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Fixed32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be greater than 5 [fixed32.gt] + * fixed32 value = 1 [(buf.validate.field).fixed32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [fixed32.gt_lt] + * fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [fixed32.gt_lt_exclusive] + * fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed32 { + * // value must be greater than or equal to 5 [fixed32.gte] + * fixed32 value = 1 [(buf.validate.field).fixed32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [fixed32.gte_lt] + * fixed32 other_value = 2 [(buf.validate.field).fixed32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [fixed32.gte_lt_exclusive] + * fixed32 another_value = 3 [(buf.validate.field).fixed32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message + * is generated. + * + * ```proto + * message MyFixed32 { + * // value must be in list [1, 2, 3] + * fixed32 value = 1 [(buf.validate.field).fixed32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFixed32 { + * // value must not be in list [1, 2, 3] + * fixed32 value = 1 [(buf.validate.field).fixed32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFixed32 { + * fixed32 value = 1 [ + * (buf.validate.field).fixed32.example = 1, + * (buf.validate.field).fixed32.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated fixed32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.Fixed32Rules. + * Use `create(Fixed32RulesSchema)` to create a new message. + */ +export const Fixed32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 13); + +/** + * Fixed64Rules describes the rules applied to `fixed64` values. + * + * @generated from message buf.validate.Fixed64Rules + */ +export type Fixed64Rules = Message<"buf.validate.Fixed64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must equal 42 + * fixed64 value = 1 [(buf.validate.field).fixed64.const = 42]; + * } + * ``` + * + * @generated from field: optional fixed64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.Fixed64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be less than 10 + * fixed64 value = 1 [(buf.validate.field).fixed64.lt = 10]; + * } + * ``` + * + * @generated from field: fixed64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be less than or equal to 10 + * fixed64 value = 1 [(buf.validate.field).fixed64.lte = 10]; + * } + * ``` + * + * @generated from field: fixed64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.Fixed64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be greater than 5 [fixed64.gt] + * fixed64 value = 1 [(buf.validate.field).fixed64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [fixed64.gt_lt] + * fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [fixed64.gt_lt_exclusive] + * fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyFixed64 { + * // value must be greater than or equal to 5 [fixed64.gte] + * fixed64 value = 1 [(buf.validate.field).fixed64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [fixed64.gte_lt] + * fixed64 other_value = 2 [(buf.validate.field).fixed64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [fixed64.gte_lt_exclusive] + * fixed64 another_value = 3 [(buf.validate.field).fixed64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: fixed64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MyFixed64 { + * // value must be in list [1, 2, 3] + * fixed64 value = 1 [(buf.validate.field).fixed64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MyFixed64 { + * // value must not be in list [1, 2, 3] + * fixed64 value = 1 [(buf.validate.field).fixed64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated fixed64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyFixed64 { + * fixed64 value = 1 [ + * (buf.validate.field).fixed64.example = 1, + * (buf.validate.field).fixed64.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated fixed64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.Fixed64Rules. + * Use `create(Fixed64RulesSchema)` to create a new message. + */ +export const Fixed64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 14); + +/** + * SFixed32Rules describes the rules applied to `fixed32` values. + * + * @generated from message buf.validate.SFixed32Rules + */ +export type SFixed32Rules = Message<"buf.validate.SFixed32Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must equal 42 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.const = 42]; + * } + * ``` + * + * @generated from field: optional sfixed32 const = 1; + */ + const: number; + + /** + * @generated from oneof buf.validate.SFixed32Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be less than 10 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.lt = 10]; + * } + * ``` + * + * @generated from field: sfixed32 lt = 2; + */ + value: number; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be less than or equal to 10 + * sfixed32 value = 1 [(buf.validate.field).sfixed32.lte = 10]; + * } + * ``` + * + * @generated from field: sfixed32 lte = 3; + */ + value: number; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SFixed32Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be greater than 5 [sfixed32.gt] + * sfixed32 value = 1 [(buf.validate.field).sfixed32.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sfixed32.gt_lt] + * sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sfixed32.gt_lt_exclusive] + * sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed32 gt = 4; + */ + value: number; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed32 { + * // value must be greater than or equal to 5 [sfixed32.gte] + * sfixed32 value = 1 [(buf.validate.field).sfixed32.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sfixed32.gte_lt] + * sfixed32 other_value = 2 [(buf.validate.field).sfixed32 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sfixed32.gte_lt_exclusive] + * sfixed32 another_value = 3 [(buf.validate.field).sfixed32 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed32 gte = 5; + */ + value: number; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySFixed32 { + * // value must be in list [1, 2, 3] + * sfixed32 value = 1 [(buf.validate.field).sfixed32 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed32 in = 6; + */ + in: number[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySFixed32 { + * // value must not be in list [1, 2, 3] + * sfixed32 value = 1 [(buf.validate.field).sfixed32 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed32 not_in = 7; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySFixed32 { + * sfixed32 value = 1 [ + * (buf.validate.field).sfixed32.example = 1, + * (buf.validate.field).sfixed32.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated sfixed32 example = 8; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.SFixed32Rules. + * Use `create(SFixed32RulesSchema)` to create a new message. + */ +export const SFixed32RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 15); + +/** + * SFixed64Rules describes the rules applied to `fixed64` values. + * + * @generated from message buf.validate.SFixed64Rules + */ +export type SFixed64Rules = Message<"buf.validate.SFixed64Rules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must equal 42 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.const = 42]; + * } + * ``` + * + * @generated from field: optional sfixed64 const = 1; + */ + const: bigint; + + /** + * @generated from oneof buf.validate.SFixed64Rules.less_than + */ + lessThan: + | { + /** + * `lt` requires the field value to be less than the specified value (field < + * value). If the field value is equal to or greater than the specified value, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be less than 10 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.lt = 10]; + * } + * ``` + * + * @generated from field: sfixed64 lt = 2; + */ + value: bigint; + case: "lt"; + } + | { + /** + * `lte` requires the field value to be less than or equal to the specified + * value (field <= value). If the field value is greater than the specified + * value, an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be less than or equal to 10 + * sfixed64 value = 1 [(buf.validate.field).sfixed64.lte = 10]; + * } + * ``` + * + * @generated from field: sfixed64 lte = 3; + */ + value: bigint; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.SFixed64Rules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the field value to be greater than the specified value + * (exclusive). If the value of `gt` is larger than a specified `lt` or + * `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be greater than 5 [sfixed64.gt] + * sfixed64 value = 1 [(buf.validate.field).sfixed64.gt = 5]; + * + * // value must be greater than 5 and less than 10 [sfixed64.gt_lt] + * sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gt: 5, lt: 10 }]; + * + * // value must be greater than 10 or less than 5 [sfixed64.gt_lt_exclusive] + * sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gt: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed64 gt = 4; + */ + value: bigint; + case: "gt"; + } + | { + /** + * `gte` requires the field value to be greater than or equal to the specified + * value (exclusive). If the value of `gte` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MySFixed64 { + * // value must be greater than or equal to 5 [sfixed64.gte] + * sfixed64 value = 1 [(buf.validate.field).sfixed64.gte = 5]; + * + * // value must be greater than or equal to 5 and less than 10 [sfixed64.gte_lt] + * sfixed64 other_value = 2 [(buf.validate.field).sfixed64 = { gte: 5, lt: 10 }]; + * + * // value must be greater than or equal to 10 or less than 5 [sfixed64.gte_lt_exclusive] + * sfixed64 another_value = 3 [(buf.validate.field).sfixed64 = { gte: 10, lt: 5 }]; + * } + * ``` + * + * @generated from field: sfixed64 gte = 5; + */ + value: bigint; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` requires the field value to be equal to one of the specified values. + * If the field value isn't one of the specified values, an error message is + * generated. + * + * ```proto + * message MySFixed64 { + * // value must be in list [1, 2, 3] + * sfixed64 value = 1 [(buf.validate.field).sfixed64 = { in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed64 in = 6; + */ + in: bigint[]; + + /** + * `not_in` requires the field value to not be equal to any of the specified + * values. If the field value is one of the specified values, an error + * message is generated. + * + * ```proto + * message MySFixed64 { + * // value must not be in list [1, 2, 3] + * sfixed64 value = 1 [(buf.validate.field).sfixed64 = { not_in: [1, 2, 3] }]; + * } + * ``` + * + * @generated from field: repeated sfixed64 not_in = 7; + */ + notIn: bigint[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MySFixed64 { + * sfixed64 value = 1 [ + * (buf.validate.field).sfixed64.example = 1, + * (buf.validate.field).sfixed64.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated sfixed64 example = 8; + */ + example: bigint[]; +}; + +/** + * Describes the message buf.validate.SFixed64Rules. + * Use `create(SFixed64RulesSchema)` to create a new message. + */ +export const SFixed64RulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 16); + +/** + * BoolRules describes the rules applied to `bool` values. These rules + * may also be applied to the `google.protobuf.BoolValue` Well-Known-Type. + * + * @generated from message buf.validate.BoolRules + */ +export type BoolRules = Message<"buf.validate.BoolRules"> & { + /** + * `const` requires the field value to exactly match the specified boolean value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBool { + * // value must equal true + * bool value = 1 [(buf.validate.field).bool.const = true]; + * } + * ``` + * + * @generated from field: optional bool const = 1; + */ + const: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyBool { + * bool value = 1 [ + * (buf.validate.field).bool.example = 1, + * (buf.validate.field).bool.example = 2 + * ]; + * } + * ``` + * + * @generated from field: repeated bool example = 2; + */ + example: boolean[]; +}; + +/** + * Describes the message buf.validate.BoolRules. + * Use `create(BoolRulesSchema)` to create a new message. + */ +export const BoolRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 17); + +/** + * StringRules describes the rules applied to `string` values These + * rules may also be applied to the `google.protobuf.StringValue` Well-Known-Type. + * + * @generated from message buf.validate.StringRules + */ +export type StringRules = Message<"buf.validate.StringRules"> & { + /** + * `const` requires the field value to exactly match the specified value. If + * the field value doesn't match, an error message is generated. + * + * ```proto + * message MyString { + * // value must equal `hello` + * string value = 1 [(buf.validate.field).string.const = "hello"]; + * } + * ``` + * + * @generated from field: optional string const = 1; + */ + const: string; + + /** + * `len` dictates that the field value must have the specified + * number of characters (Unicode code points), which may differ from the number + * of bytes in the string. If the field value does not meet the specified + * length, an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be 5 characters + * string value = 1 [(buf.validate.field).string.len = 5]; + * } + * ``` + * + * @generated from field: optional uint64 len = 19; + */ + len: bigint; + + /** + * `min_len` specifies that the field value must have at least the specified + * number of characters (Unicode code points), which may differ from the number + * of bytes in the string. If the field value contains fewer characters, an error + * message will be generated. + * + * ```proto + * message MyString { + * // value length must be at least 3 characters + * string value = 1 [(buf.validate.field).string.min_len = 3]; + * } + * ``` + * + * @generated from field: optional uint64 min_len = 2; + */ + minLen: bigint; + + /** + * `max_len` specifies that the field value must have no more than the specified + * number of characters (Unicode code points), which may differ from the + * number of bytes in the string. If the field value contains more characters, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be at most 10 characters + * string value = 1 [(buf.validate.field).string.max_len = 10]; + * } + * ``` + * + * @generated from field: optional uint64 max_len = 3; + */ + maxLen: bigint; + + /** + * `len_bytes` dictates that the field value must have the specified number of + * bytes. If the field value does not match the specified length in bytes, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value length must be 6 bytes + * string value = 1 [(buf.validate.field).string.len_bytes = 6]; + * } + * ``` + * + * @generated from field: optional uint64 len_bytes = 20; + */ + lenBytes: bigint; + + /** + * `min_bytes` specifies that the field value must have at least the specified + * number of bytes. If the field value contains fewer bytes, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value length must be at least 4 bytes + * string value = 1 [(buf.validate.field).string.min_bytes = 4]; + * } + * + * ``` + * + * @generated from field: optional uint64 min_bytes = 4; + */ + minBytes: bigint; + + /** + * `max_bytes` specifies that the field value must have no more than the + * specified number of bytes. If the field value contains more bytes, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value length must be at most 8 bytes + * string value = 1 [(buf.validate.field).string.max_bytes = 8]; + * } + * ``` + * + * @generated from field: optional uint64 max_bytes = 5; + */ + maxBytes: bigint; + + /** + * `pattern` specifies that the field value must match the specified + * regular expression (RE2 syntax), with the expression provided without any + * delimiters. If the field value doesn't match the regular expression, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value does not match regex pattern `^[a-zA-Z]//$` + * string value = 1 [(buf.validate.field).string.pattern = "^[a-zA-Z]//$"]; + * } + * ``` + * + * @generated from field: optional string pattern = 6; + */ + pattern: string; + + /** + * `prefix` specifies that the field value must have the + * specified substring at the beginning of the string. If the field value + * doesn't start with the specified prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value does not have prefix `pre` + * string value = 1 [(buf.validate.field).string.prefix = "pre"]; + * } + * ``` + * + * @generated from field: optional string prefix = 7; + */ + prefix: string; + + /** + * `suffix` specifies that the field value must have the + * specified substring at the end of the string. If the field value doesn't + * end with the specified suffix, an error message will be generated. + * + * ```proto + * message MyString { + * // value does not have suffix `post` + * string value = 1 [(buf.validate.field).string.suffix = "post"]; + * } + * ``` + * + * @generated from field: optional string suffix = 8; + */ + suffix: string; + + /** + * `contains` specifies that the field value must have the + * specified substring anywhere in the string. If the field value doesn't + * contain the specified substring, an error message will be generated. + * + * ```proto + * message MyString { + * // value does not contain substring `inside`. + * string value = 1 [(buf.validate.field).string.contains = "inside"]; + * } + * ``` + * + * @generated from field: optional string contains = 9; + */ + contains: string; + + /** + * `not_contains` specifies that the field value must not have the + * specified substring anywhere in the string. If the field value contains + * the specified substring, an error message will be generated. + * + * ```proto + * message MyString { + * // value contains substring `inside`. + * string value = 1 [(buf.validate.field).string.not_contains = "inside"]; + * } + * ``` + * + * @generated from field: optional string not_contains = 23; + */ + notContains: string; + + /** + * `in` specifies that the field value must be equal to one of the specified + * values. If the field value isn't one of the specified values, an error + * message will be generated. + * + * ```proto + * message MyString { + * // value must be in list ["apple", "banana"] + * string value = 1 [(buf.validate.field).string.in = "apple", (buf.validate.field).string.in = "banana"]; + * } + * ``` + * + * @generated from field: repeated string in = 10; + */ + in: string[]; + + /** + * `not_in` specifies that the field value cannot be equal to any + * of the specified values. If the field value is one of the specified values, + * an error message will be generated. + * ```proto + * message MyString { + * // value must not be in list ["orange", "grape"] + * string value = 1 [(buf.validate.field).string.not_in = "orange", (buf.validate.field).string.not_in = "grape"]; + * } + * ``` + * + * @generated from field: repeated string not_in = 11; + */ + notIn: string[]; + + /** + * `WellKnown` rules provide advanced rules against common string + * patterns. + * + * @generated from oneof buf.validate.StringRules.well_known + */ + wellKnown: + | { + /** + * `email` specifies that the field value must be a valid email address, for + * example "foo@example.com". + * + * Conforms to the definition for a valid email address from the [HTML standard](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address). + * Note that this standard willfully deviates from [RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322), + * which allows many unexpected forms of email addresses and will easily match + * a typographical error. + * + * If the field value isn't a valid email address, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid email address + * string value = 1 [(buf.validate.field).string.email = true]; + * } + * ``` + * + * @generated from field: bool email = 12; + */ + value: boolean; + case: "email"; + } + | { + /** + * `hostname` specifies that the field value must be a valid hostname, for + * example "foo.example.com". + * + * A valid hostname follows the rules below: + * - The name consists of one or more labels, separated by a dot ("."). + * - Each label can be 1 to 63 alphanumeric characters. + * - A label can contain hyphens ("-"), but must not start or end with a hyphen. + * - The right-most label must not be digits only. + * - The name can have a trailing dot—for example, "foo.example.com.". + * - The name can be 253 characters at most, excluding the optional trailing dot. + * + * If the field value isn't a valid hostname, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid hostname + * string value = 1 [(buf.validate.field).string.hostname = true]; + * } + * ``` + * + * @generated from field: bool hostname = 13; + */ + value: boolean; + case: "hostname"; + } + | { + /** + * `ip` specifies that the field value must be a valid IP (v4 or v6) address. + * + * IPv4 addresses are expected in the dotted decimal format—for example, "192.168.5.21". + * IPv6 addresses are expected in their text representation—for example, "::1", + * or "2001:0DB8:ABCD:0012::0". + * + * Both formats are well-defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + * Zone identifiers for IPv6 addresses (for example, "fe80::a%en1") are supported. + * + * If the field value isn't a valid IP address, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid IP address + * string value = 1 [(buf.validate.field).string.ip = true]; + * } + * ``` + * + * @generated from field: bool ip = 14; + */ + value: boolean; + case: "ip"; + } + | { + /** + * `ipv4` specifies that the field value must be a valid IPv4 address—for + * example "192.168.5.21". If the field value isn't a valid IPv4 address, an + * error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv4 address + * string value = 1 [(buf.validate.field).string.ipv4 = true]; + * } + * ``` + * + * @generated from field: bool ipv4 = 15; + */ + value: boolean; + case: "ipv4"; + } + | { + /** + * `ipv6` specifies that the field value must be a valid IPv6 address—for + * example "::1", or "d7a:115c:a1e0:ab12:4843:cd96:626b:430b". If the field + * value is not a valid IPv6 address, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv6 address + * string value = 1 [(buf.validate.field).string.ipv6 = true]; + * } + * ``` + * + * @generated from field: bool ipv6 = 16; + */ + value: boolean; + case: "ipv6"; + } + | { + /** + * `uri` specifies that the field value must be a valid URI, for example + * "https://example.com/foo/bar?baz=quux#frag". + * + * URI is defined in the internet standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). + * Zone Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + * + * If the field value isn't a valid URI, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid URI + * string value = 1 [(buf.validate.field).string.uri = true]; + * } + * ``` + * + * @generated from field: bool uri = 17; + */ + value: boolean; + case: "uri"; + } + | { + /** + * `uri_ref` specifies that the field value must be a valid URI Reference—either + * a URI such as "https://example.com/foo/bar?baz=quux#frag", or a Relative + * Reference such as "./foo/bar?query". + * + * URI, URI Reference, and Relative Reference are defined in the internet + * standard [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). Zone + * Identifiers in IPv6 address literals are supported ([RFC 6874](https://datatracker.ietf.org/doc/html/rfc6874)). + * + * If the field value isn't a valid URI Reference, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid URI Reference + * string value = 1 [(buf.validate.field).string.uri_ref = true]; + * } + * ``` + * + * @generated from field: bool uri_ref = 18; + */ + value: boolean; + case: "uriRef"; + } + | { + /** + * `address` specifies that the field value must be either a valid hostname + * (for example, "example.com"), or a valid IP (v4 or v6) address (for example, + * "192.168.0.1", or "::1"). If the field value isn't a valid hostname or IP, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid hostname, or ip address + * string value = 1 [(buf.validate.field).string.address = true]; + * } + * ``` + * + * @generated from field: bool address = 21; + */ + value: boolean; + case: "address"; + } + | { + /** + * `uuid` specifies that the field value must be a valid UUID as defined by + * [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2). If the + * field value isn't a valid UUID, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid UUID + * string value = 1 [(buf.validate.field).string.uuid = true]; + * } + * ``` + * + * @generated from field: bool uuid = 22; + */ + value: boolean; + case: "uuid"; + } + | { + /** + * `tuuid` (trimmed UUID) specifies that the field value must be a valid UUID as + * defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122#section-4.1.2) with all dashes + * omitted. If the field value isn't a valid UUID without dashes, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value must be a valid trimmed UUID + * string value = 1 [(buf.validate.field).string.tuuid = true]; + * } + * ``` + * + * @generated from field: bool tuuid = 33; + */ + value: boolean; + case: "tuuid"; + } + | { + /** + * `ip_with_prefixlen` specifies that the field value must be a valid IP + * (v4 or v6) address with prefix length—for example, "192.168.5.21/16" or + * "2001:0DB8:ABCD:0012::F1/64". If the field value isn't a valid IP with + * prefix length, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IP with prefix length + * string value = 1 [(buf.validate.field).string.ip_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ip_with_prefixlen = 26; + */ + value: boolean; + case: "ipWithPrefixlen"; + } + | { + /** + * `ipv4_with_prefixlen` specifies that the field value must be a valid + * IPv4 address with prefix length—for example, "192.168.5.21/16". If the + * field value isn't a valid IPv4 address with prefix length, an error + * message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv4 address with prefix length + * string value = 1 [(buf.validate.field).string.ipv4_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ipv4_with_prefixlen = 27; + */ + value: boolean; + case: "ipv4WithPrefixlen"; + } + | { + /** + * `ipv6_with_prefixlen` specifies that the field value must be a valid + * IPv6 address with prefix length—for example, "2001:0DB8:ABCD:0012::F1/64". + * If the field value is not a valid IPv6 address with prefix length, + * an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv6 address prefix length + * string value = 1 [(buf.validate.field).string.ipv6_with_prefixlen = true]; + * } + * ``` + * + * @generated from field: bool ipv6_with_prefixlen = 28; + */ + value: boolean; + case: "ipv6WithPrefixlen"; + } + | { + /** + * `ip_prefix` specifies that the field value must be a valid IP (v4 or v6) + * prefix—for example, "192.168.0.0/16" or "2001:0DB8:ABCD:0012::0/64". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + * prefix, and the remaining 64 bits must be zero. + * + * If the field value isn't a valid IP prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid IP prefix + * string value = 1 [(buf.validate.field).string.ip_prefix = true]; + * } + * ``` + * + * @generated from field: bool ip_prefix = 29; + */ + value: boolean; + case: "ipPrefix"; + } + | { + /** + * `ipv4_prefix` specifies that the field value must be a valid IPv4 + * prefix, for example "192.168.0.0/16". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "192.168.0.0/16" designates the left-most 16 bits for the prefix, + * and the remaining 16 bits must be zero. + * + * If the field value isn't a valid IPv4 prefix, an error message + * will be generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv4 prefix + * string value = 1 [(buf.validate.field).string.ipv4_prefix = true]; + * } + * ``` + * + * @generated from field: bool ipv4_prefix = 30; + */ + value: boolean; + case: "ipv4Prefix"; + } + | { + /** + * `ipv6_prefix` specifies that the field value must be a valid IPv6 prefix—for + * example, "2001:0DB8:ABCD:0012::0/64". + * + * The prefix must have all zeros for the unmasked bits. For example, + * "2001:0DB8:ABCD:0012::0/64" designates the left-most 64 bits for the + * prefix, and the remaining 64 bits must be zero. + * + * If the field value is not a valid IPv6 prefix, an error message will be + * generated. + * + * ```proto + * message MyString { + * // value must be a valid IPv6 prefix + * string value = 1 [(buf.validate.field).string.ipv6_prefix = true]; + * } + * ``` + * + * @generated from field: bool ipv6_prefix = 31; + */ + value: boolean; + case: "ipv6Prefix"; + } + | { + /** + * `host_and_port` specifies that the field value must be valid host/port + * pair—for example, "example.com:8080". + * + * The host can be one of: + * - An IPv4 address in dotted decimal format—for example, "192.168.5.21". + * - An IPv6 address enclosed in square brackets—for example, "[2001:0DB8:ABCD:0012::F1]". + * - A hostname—for example, "example.com". + * + * The port is separated by a colon. It must be non-empty, with a decimal number + * in the range of 0-65535, inclusive. + * + * @generated from field: bool host_and_port = 32; + */ + value: boolean; + case: "hostAndPort"; + } + | { + /** + * `well_known_regex` specifies a common well-known pattern + * defined as a regex. If the field value doesn't match the well-known + * regex, an error message will be generated. + * + * ```proto + * message MyString { + * // value must be a valid HTTP header value + * string value = 1 [(buf.validate.field).string.well_known_regex = KNOWN_REGEX_HTTP_HEADER_VALUE]; + * } + * ``` + * + * #### KnownRegex + * + * `well_known_regex` contains some well-known patterns. + * + * | Name | Number | Description | + * |-------------------------------|--------|-------------------------------------------| + * | KNOWN_REGEX_UNSPECIFIED | 0 | | + * | KNOWN_REGEX_HTTP_HEADER_NAME | 1 | HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2) | + * | KNOWN_REGEX_HTTP_HEADER_VALUE | 2 | HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4) | + * + * @generated from field: buf.validate.KnownRegex well_known_regex = 24; + */ + value: KnownRegex; + case: "wellKnownRegex"; + } + | { case: undefined; value?: undefined }; + + /** + * This applies to regexes `HTTP_HEADER_NAME` and `HTTP_HEADER_VALUE` to + * enable strict header validation. By default, this is true, and HTTP header + * validations are [RFC-compliant](https://datatracker.ietf.org/doc/html/rfc7230#section-3). Setting to false will enable looser + * validations that only disallow `\r\n\0` characters, which can be used to + * bypass header matching rules. + * + * ```proto + * message MyString { + * // The field `value` must have be a valid HTTP headers, but not enforced with strict rules. + * string value = 1 [(buf.validate.field).string.strict = false]; + * } + * ``` + * + * @generated from field: optional bool strict = 25; + */ + strict: boolean; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyString { + * string value = 1 [ + * (buf.validate.field).string.example = "hello", + * (buf.validate.field).string.example = "world" + * ]; + * } + * ``` + * + * @generated from field: repeated string example = 34; + */ + example: string[]; +}; + +/** + * Describes the message buf.validate.StringRules. + * Use `create(StringRulesSchema)` to create a new message. + */ +export const StringRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 18); + +/** + * BytesRules describe the rules applied to `bytes` values. These rules + * may also be applied to the `google.protobuf.BytesValue` Well-Known-Type. + * + * @generated from message buf.validate.BytesRules + */ +export type BytesRules = Message<"buf.validate.BytesRules"> & { + /** + * `const` requires the field value to exactly match the specified bytes + * value. If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be "\x01\x02\x03\x04" + * bytes value = 1 [(buf.validate.field).bytes.const = "\x01\x02\x03\x04"]; + * } + * ``` + * + * @generated from field: optional bytes const = 1; + */ + const: Uint8Array; + + /** + * `len` requires the field value to have the specified length in bytes. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * message MyBytes { + * // value length must be 4 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.len = 4]; + * } + * ``` + * + * @generated from field: optional uint64 len = 13; + */ + len: bigint; + + /** + * `min_len` requires the field value to have at least the specified minimum + * length in bytes. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value length must be at least 2 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.min_len = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_len = 2; + */ + minLen: bigint; + + /** + * `max_len` requires the field value to have at most the specified maximum + * length in bytes. + * If the field value exceeds the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be at most 6 bytes. + * optional bytes value = 1 [(buf.validate.field).bytes.max_len = 6]; + * } + * ``` + * + * @generated from field: optional uint64 max_len = 3; + */ + maxLen: bigint; + + /** + * `pattern` requires the field value to match the specified regular + * expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax)). + * The value of the field must be valid UTF-8 or validation will fail with a + * runtime error. + * If the field value doesn't match the pattern, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must match regex pattern "^[a-zA-Z0-9]+$". + * optional bytes value = 1 [(buf.validate.field).bytes.pattern = "^[a-zA-Z0-9]+$"]; + * } + * ``` + * + * @generated from field: optional string pattern = 4; + */ + pattern: string; + + /** + * `prefix` requires the field value to have the specified bytes at the + * beginning of the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value does not have prefix \x01\x02 + * optional bytes value = 1 [(buf.validate.field).bytes.prefix = "\x01\x02"]; + * } + * ``` + * + * @generated from field: optional bytes prefix = 5; + */ + prefix: Uint8Array; + + /** + * `suffix` requires the field value to have the specified bytes at the end + * of the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```proto + * message MyBytes { + * // value does not have suffix \x03\x04 + * optional bytes value = 1 [(buf.validate.field).bytes.suffix = "\x03\x04"]; + * } + * ``` + * + * @generated from field: optional bytes suffix = 6; + */ + suffix: Uint8Array; + + /** + * `contains` requires the field value to have the specified bytes anywhere in + * the string. + * If the field value doesn't meet the requirement, an error message is generated. + * + * ```protobuf + * message MyBytes { + * // value does not contain \x02\x03 + * optional bytes value = 1 [(buf.validate.field).bytes.contains = "\x02\x03"]; + * } + * ``` + * + * @generated from field: optional bytes contains = 7; + */ + contains: Uint8Array; + + /** + * `in` requires the field value to be equal to one of the specified + * values. If the field value doesn't match any of the specified values, an + * error message is generated. + * + * ```protobuf + * message MyBytes { + * // value must in ["\x01\x02", "\x02\x03", "\x03\x04"] + * optional bytes value = 1 [(buf.validate.field).bytes.in = {"\x01\x02", "\x02\x03", "\x03\x04"}]; + * } + * ``` + * + * @generated from field: repeated bytes in = 8; + */ + in: Uint8Array[]; + + /** + * `not_in` requires the field value to be not equal to any of the specified + * values. + * If the field value matches any of the specified values, an error message is + * generated. + * + * ```proto + * message MyBytes { + * // value must not in ["\x01\x02", "\x02\x03", "\x03\x04"] + * optional bytes value = 1 [(buf.validate.field).bytes.not_in = {"\x01\x02", "\x02\x03", "\x03\x04"}]; + * } + * ``` + * + * @generated from field: repeated bytes not_in = 9; + */ + notIn: Uint8Array[]; + + /** + * WellKnown rules provide advanced rules against common byte + * patterns + * + * @generated from oneof buf.validate.BytesRules.well_known + */ + wellKnown: + | { + /** + * `ip` ensures that the field `value` is a valid IP address (v4 or v6) in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be a valid IP address + * optional bytes value = 1 [(buf.validate.field).bytes.ip = true]; + * } + * ``` + * + * @generated from field: bool ip = 10; + */ + value: boolean; + case: "ip"; + } + | { + /** + * `ipv4` ensures that the field `value` is a valid IPv4 address in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * + * ```proto + * message MyBytes { + * // value must be a valid IPv4 address + * optional bytes value = 1 [(buf.validate.field).bytes.ipv4 = true]; + * } + * ``` + * + * @generated from field: bool ipv4 = 11; + */ + value: boolean; + case: "ipv4"; + } + | { + /** + * `ipv6` ensures that the field `value` is a valid IPv6 address in byte format. + * If the field value doesn't meet this rule, an error message is generated. + * ```proto + * message MyBytes { + * // value must be a valid IPv6 address + * optional bytes value = 1 [(buf.validate.field).bytes.ipv6 = true]; + * } + * ``` + * + * @generated from field: bool ipv6 = 12; + */ + value: boolean; + case: "ipv6"; + } + | { case: undefined; value?: undefined }; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyBytes { + * bytes value = 1 [ + * (buf.validate.field).bytes.example = "\x01\x02", + * (buf.validate.field).bytes.example = "\x02\x03" + * ]; + * } + * ``` + * + * @generated from field: repeated bytes example = 14; + */ + example: Uint8Array[]; +}; + +/** + * Describes the message buf.validate.BytesRules. + * Use `create(BytesRulesSchema)` to create a new message. + */ +export const BytesRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 19); + +/** + * EnumRules describe the rules applied to `enum` values. + * + * @generated from message buf.validate.EnumRules + */ +export type EnumRules = Message<"buf.validate.EnumRules"> & { + /** + * `const` requires the field value to exactly match the specified enum value. + * If the field value doesn't match, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be exactly MY_ENUM_VALUE1. + * MyEnum value = 1 [(buf.validate.field).enum.const = 1]; + * } + * ``` + * + * @generated from field: optional int32 const = 1; + */ + const: number; + + /** + * `defined_only` requires the field value to be one of the defined values for + * this enum, failing on any undefined value. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be a defined value of MyEnum. + * MyEnum value = 1 [(buf.validate.field).enum.defined_only = true]; + * } + * ``` + * + * @generated from field: optional bool defined_only = 2; + */ + definedOnly: boolean; + + /** + * `in` requires the field value to be equal to one of the + * specified enum values. If the field value doesn't match any of the + * specified values, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must be equal to one of the specified values. + * MyEnum value = 1 [(buf.validate.field).enum = { in: [1, 2]}]; + * } + * ``` + * + * @generated from field: repeated int32 in = 3; + */ + in: number[]; + + /** + * `not_in` requires the field value to be not equal to any of the + * specified enum values. If the field value matches one of the specified + * values, an error message is generated. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * // The field `value` must not be equal to any of the specified values. + * MyEnum value = 1 [(buf.validate.field).enum = { not_in: [1, 2]}]; + * } + * ``` + * + * @generated from field: repeated int32 not_in = 4; + */ + notIn: number[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * enum MyEnum { + * MY_ENUM_UNSPECIFIED = 0; + * MY_ENUM_VALUE1 = 1; + * MY_ENUM_VALUE2 = 2; + * } + * + * message MyMessage { + * (buf.validate.field).enum.example = 1, + * (buf.validate.field).enum.example = 2 + * } + * ``` + * + * @generated from field: repeated int32 example = 5; + */ + example: number[]; +}; + +/** + * Describes the message buf.validate.EnumRules. + * Use `create(EnumRulesSchema)` to create a new message. + */ +export const EnumRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 20); + +/** + * RepeatedRules describe the rules applied to `repeated` values. + * + * @generated from message buf.validate.RepeatedRules + */ +export type RepeatedRules = Message<"buf.validate.RepeatedRules"> & { + /** + * `min_items` requires that this field must contain at least the specified + * minimum number of items. + * + * Note that `min_items = 1` is equivalent to setting a field as `required`. + * + * ```proto + * message MyRepeated { + * // value must contain at least 2 items + * repeated string value = 1 [(buf.validate.field).repeated.min_items = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_items = 1; + */ + minItems: bigint; + + /** + * `max_items` denotes that this field must not exceed a + * certain number of items as the upper limit. If the field contains more + * items than specified, an error message will be generated, requiring the + * field to maintain no more than the specified number of items. + * + * ```proto + * message MyRepeated { + * // value must contain no more than 3 item(s) + * repeated string value = 1 [(buf.validate.field).repeated.max_items = 3]; + * } + * ``` + * + * @generated from field: optional uint64 max_items = 2; + */ + maxItems: bigint; + + /** + * `unique` indicates that all elements in this field must + * be unique. This rule is strictly applicable to scalar and enum + * types, with message types not being supported. + * + * ```proto + * message MyRepeated { + * // repeated value must contain unique items + * repeated string value = 1 [(buf.validate.field).repeated.unique = true]; + * } + * ``` + * + * @generated from field: optional bool unique = 3; + */ + unique: boolean; + + /** + * `items` details the rules to be applied to each item + * in the field. Even for repeated message fields, validation is executed + * against each item unless skip is explicitly specified. + * + * ```proto + * message MyRepeated { + * // The items in the field `value` must follow the specified rules. + * repeated string value = 1 [(buf.validate.field).repeated.items = { + * string: { + * min_len: 3 + * max_len: 10 + * } + * }]; + * } + * ``` + * + * @generated from field: optional buf.validate.FieldRules items = 4; + */ + items?: FieldRules; +}; + +/** + * Describes the message buf.validate.RepeatedRules. + * Use `create(RepeatedRulesSchema)` to create a new message. + */ +export const RepeatedRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 21); + +/** + * MapRules describe the rules applied to `map` values. + * + * @generated from message buf.validate.MapRules + */ +export type MapRules = Message<"buf.validate.MapRules"> & { + /** + * Specifies the minimum number of key-value pairs allowed. If the field has + * fewer key-value pairs than specified, an error message is generated. + * + * ```proto + * message MyMap { + * // The field `value` must have at least 2 key-value pairs. + * map value = 1 [(buf.validate.field).map.min_pairs = 2]; + * } + * ``` + * + * @generated from field: optional uint64 min_pairs = 1; + */ + minPairs: bigint; + + /** + * Specifies the maximum number of key-value pairs allowed. If the field has + * more key-value pairs than specified, an error message is generated. + * + * ```proto + * message MyMap { + * // The field `value` must have at most 3 key-value pairs. + * map value = 1 [(buf.validate.field).map.max_pairs = 3]; + * } + * ``` + * + * @generated from field: optional uint64 max_pairs = 2; + */ + maxPairs: bigint; + + /** + * Specifies the rules to be applied to each key in the field. + * + * ```proto + * message MyMap { + * // The keys in the field `value` must follow the specified rules. + * map value = 1 [(buf.validate.field).map.keys = { + * string: { + * min_len: 3 + * max_len: 10 + * } + * }]; + * } + * ``` + * + * @generated from field: optional buf.validate.FieldRules keys = 4; + */ + keys?: FieldRules; + + /** + * Specifies the rules to be applied to the value of each key in the + * field. Message values will still have their validations evaluated unless + * skip is specified here. + * + * ```proto + * message MyMap { + * // The values in the field `value` must follow the specified rules. + * map value = 1 [(buf.validate.field).map.values = { + * string: { + * min_len: 5 + * max_len: 20 + * } + * }]; + * } + * ``` + * + * @generated from field: optional buf.validate.FieldRules values = 5; + */ + values?: FieldRules; +}; + +/** + * Describes the message buf.validate.MapRules. + * Use `create(MapRulesSchema)` to create a new message. + */ +export const MapRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 22); + +/** + * AnyRules describe rules applied exclusively to the `google.protobuf.Any` well-known type. + * + * @generated from message buf.validate.AnyRules + */ +export type AnyRules = Message<"buf.validate.AnyRules"> & { + /** + * `in` requires the field's `type_url` to be equal to one of the + * specified values. If it doesn't match any of the specified values, an error + * message is generated. + * + * ```proto + * message MyAny { + * // The `value` field must have a `type_url` equal to one of the specified values. + * google.protobuf.Any value = 1 [(buf.validate.field).any.in = ["type.googleapis.com/MyType1", "type.googleapis.com/MyType2"]]; + * } + * ``` + * + * @generated from field: repeated string in = 2; + */ + in: string[]; + + /** + * requires the field's type_url to be not equal to any of the specified values. If it matches any of the specified values, an error message is generated. + * + * ```proto + * message MyAny { + * // The field `value` must not have a `type_url` equal to any of the specified values. + * google.protobuf.Any value = 1 [(buf.validate.field).any.not_in = ["type.googleapis.com/ForbiddenType1", "type.googleapis.com/ForbiddenType2"]]; + * } + * ``` + * + * @generated from field: repeated string not_in = 3; + */ + notIn: string[]; +}; + +/** + * Describes the message buf.validate.AnyRules. + * Use `create(AnyRulesSchema)` to create a new message. + */ +export const AnyRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 23); + +/** + * DurationRules describe the rules applied exclusively to the `google.protobuf.Duration` well-known type. + * + * @generated from message buf.validate.DurationRules + */ +export type DurationRules = Message<"buf.validate.DurationRules"> & { + /** + * `const` dictates that the field must match the specified value of the `google.protobuf.Duration` type exactly. + * If the field's value deviates from the specified value, an error message + * will be generated. + * + * ```proto + * message MyDuration { + * // value must equal 5s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.const = "5s"]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Duration const = 2; + */ + const?: Duration; + + /** + * @generated from oneof buf.validate.DurationRules.less_than + */ + lessThan: + | { + /** + * `lt` stipulates that the field must be less than the specified value of the `google.protobuf.Duration` type, + * exclusive. If the field's value is greater than or equal to the specified + * value, an error message will be generated. + * + * ```proto + * message MyDuration { + * // value must be less than 5s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lt = "5s"]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration lt = 3; + */ + value: Duration; + case: "lt"; + } + | { + /** + * `lte` indicates that the field must be less than or equal to the specified + * value of the `google.protobuf.Duration` type, inclusive. If the field's value is greater than the specified value, + * an error message will be generated. + * + * ```proto + * message MyDuration { + * // value must be less than or equal to 10s + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lte = "10s"]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration lte = 4; + */ + value: Duration; + case: "lte"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.DurationRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the duration field value to be greater than the specified + * value (exclusive). If the value of `gt` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be greater than 5s [duration.gt] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.gt = { seconds: 5 }]; + * + * // duration must be greater than 5s and less than 10s [duration.gt_lt] + * google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gt: { seconds: 5 }, lt: { seconds: 10 } }]; + * + * // duration must be greater than 10s or less than 5s [duration.gt_lt_exclusive] + * google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gt: { seconds: 10 }, lt: { seconds: 5 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration gt = 5; + */ + value: Duration; + case: "gt"; + } + | { + /** + * `gte` requires the duration field value to be greater than or equal to the + * specified value (exclusive). If the value of `gte` is larger than a + * specified `lt` or `lte`, the range is reversed, and the field value must + * be outside the specified range. If the field value doesn't meet the + * required conditions, an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be greater than or equal to 5s [duration.gte] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.gte = { seconds: 5 }]; + * + * // duration must be greater than or equal to 5s and less than 10s [duration.gte_lt] + * google.protobuf.Duration another_value = 2 [(buf.validate.field).duration = { gte: { seconds: 5 }, lt: { seconds: 10 } }]; + * + * // duration must be greater than or equal to 10s or less than 5s [duration.gte_lt_exclusive] + * google.protobuf.Duration other_value = 3 [(buf.validate.field).duration = { gte: { seconds: 10 }, lt: { seconds: 5 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Duration gte = 6; + */ + value: Duration; + case: "gte"; + } + | { case: undefined; value?: undefined }; + + /** + * `in` asserts that the field must be equal to one of the specified values of the `google.protobuf.Duration` type. + * If the field's value doesn't correspond to any of the specified values, + * an error message will be generated. + * + * ```proto + * message MyDuration { + * // value must be in list [1s, 2s, 3s] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.in = ["1s", "2s", "3s"]]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration in = 7; + */ + in: Duration[]; + + /** + * `not_in` denotes that the field must not be equal to + * any of the specified values of the `google.protobuf.Duration` type. + * If the field's value matches any of these values, an error message will be + * generated. + * + * ```proto + * message MyDuration { + * // value must not be in list [1s, 2s, 3s] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.not_in = ["1s", "2s", "3s"]]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration not_in = 8; + */ + notIn: Duration[]; + + /** + * `example` specifies values that the field may have. These values SHOULD + * conform to other rules. `example` values will not impact validation + * but may be used as helpful guidance on how to populate the given field. + * + * ```proto + * message MyDuration { + * google.protobuf.Duration value = 1 [ + * (buf.validate.field).duration.example = { seconds: 1 }, + * (buf.validate.field).duration.example = { seconds: 2 }, + * ]; + * } + * ``` + * + * @generated from field: repeated google.protobuf.Duration example = 9; + */ + example: Duration[]; +}; + +/** + * Describes the message buf.validate.DurationRules. + * Use `create(DurationRulesSchema)` to create a new message. + */ +export const DurationRulesSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 24); + +/** + * TimestampRules describe the rules applied exclusively to the `google.protobuf.Timestamp` well-known type. + * + * @generated from message buf.validate.TimestampRules + */ +export type TimestampRules = Message<"buf.validate.TimestampRules"> & { + /** + * `const` dictates that this field, of the `google.protobuf.Timestamp` type, must exactly match the specified value. If the field value doesn't correspond to the specified timestamp, an error message will be generated. + * + * ```proto + * message MyTimestamp { + * // value must equal 2023-05-03T10:00:00Z + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.const = {seconds: 1727998800}]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Timestamp const = 2; + */ + const?: Timestamp; + + /** + * @generated from oneof buf.validate.TimestampRules.less_than + */ + lessThan: + | { + /** + * requires the duration field value to be less than the specified value (field < value). If the field value doesn't meet the required conditions, an error message is generated. + * + * ```proto + * message MyDuration { + * // duration must be less than 'P3D' [duration.lt] + * google.protobuf.Duration value = 1 [(buf.validate.field).duration.lt = { seconds: 259200 }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp lt = 3; + */ + value: Timestamp; + case: "lt"; + } + | { + /** + * requires the timestamp field value to be less than or equal to the specified value (field <= value). If the field value doesn't meet the required conditions, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be less than or equal to '2023-05-14T00:00:00Z' [timestamp.lte] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.lte = { seconds: 1678867200 }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp lte = 4; + */ + value: Timestamp; + case: "lte"; + } + | { + /** + * `lt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be less than the current time. `lt_now` can only be used with the `within` rule. + * + * ```proto + * message MyTimestamp { + * // value must be less than now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.lt_now = true]; + * } + * ``` + * + * @generated from field: bool lt_now = 7; + */ + value: boolean; + case: "ltNow"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from oneof buf.validate.TimestampRules.greater_than + */ + greaterThan: + | { + /** + * `gt` requires the timestamp field value to be greater than the specified + * value (exclusive). If the value of `gt` is larger than a specified `lt` + * or `lte`, the range is reversed, and the field value must be outside the + * specified range. If the field value doesn't meet the required conditions, + * an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be greater than '2023-01-01T00:00:00Z' [timestamp.gt] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gt = { seconds: 1672444800 }]; + * + * // timestamp must be greater than '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gt_lt] + * google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gt: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + * + * // timestamp must be greater than '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gt_lt_exclusive] + * google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gt: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp gt = 5; + */ + value: Timestamp; + case: "gt"; + } + | { + /** + * `gte` requires the timestamp field value to be greater than or equal to the + * specified value (exclusive). If the value of `gte` is larger than a + * specified `lt` or `lte`, the range is reversed, and the field value + * must be outside the specified range. If the field value doesn't meet + * the required conditions, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' [timestamp.gte] + * google.protobuf.Timestamp value = 1 [(buf.validate.field).timestamp.gte = { seconds: 1672444800 }]; + * + * // timestamp must be greater than or equal to '2023-01-01T00:00:00Z' and less than '2023-01-02T00:00:00Z' [timestamp.gte_lt] + * google.protobuf.Timestamp another_value = 2 [(buf.validate.field).timestamp = { gte: { seconds: 1672444800 }, lt: { seconds: 1672531200 } }]; + * + * // timestamp must be greater than or equal to '2023-01-02T00:00:00Z' or less than '2023-01-01T00:00:00Z' [timestamp.gte_lt_exclusive] + * google.protobuf.Timestamp other_value = 3 [(buf.validate.field).timestamp = { gte: { seconds: 1672531200 }, lt: { seconds: 1672444800 } }]; + * } + * ``` + * + * @generated from field: google.protobuf.Timestamp gte = 6; + */ + value: Timestamp; + case: "gte"; + } + | { + /** + * `gt_now` specifies that this field, of the `google.protobuf.Timestamp` type, must be greater than the current time. `gt_now` can only be used with the `within` rule. + * + * ```proto + * message MyTimestamp { + * // value must be greater than now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.gt_now = true]; + * } + * ``` + * + * @generated from field: bool gt_now = 8; + */ + value: boolean; + case: "gtNow"; + } + | { case: undefined; value?: undefined }; + + /** + * `within` specifies that this field, of the `google.protobuf.Timestamp` type, must be within the specified duration of the current time. If the field value isn't within the duration, an error message is generated. + * + * ```proto + * message MyTimestamp { + * // value must be within 1 hour of now + * google.protobuf.Timestamp created_at = 1 [(buf.validate.field).timestamp.within = {seconds: 3600}]; + * } + * ``` + * + * @generated from field: optional google.protobuf.Duration within = 9; + */ + within?: Duration; + + /** + * @generated from field: repeated google.protobuf.Timestamp example = 10; + */ + example: Timestamp[]; +}; + +/** + * Describes the message buf.validate.TimestampRules. + * Use `create(TimestampRulesSchema)` to create a new message. + */ +export const TimestampRulesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 25); + +/** + * `Violations` is a collection of `Violation` messages. This message type is returned by + * protovalidate when a proto message fails to meet the requirements set by the `Rule` validation rules. + * Each individual violation is represented by a `Violation` message. + * + * @generated from message buf.validate.Violations + */ +export type Violations = Message<"buf.validate.Violations"> & { + /** + * `violations` is a repeated field that contains all the `Violation` messages corresponding to the violations detected. + * + * @generated from field: repeated buf.validate.Violation violations = 1; + */ + violations: Violation[]; +}; + +/** + * Describes the message buf.validate.Violations. + * Use `create(ViolationsSchema)` to create a new message. + */ +export const ViolationsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 26); + +/** + * `Violation` represents a single instance where a validation rule, expressed + * as a `Rule`, was not met. It provides information about the field that + * caused the violation, the specific rule that wasn't fulfilled, and a + * human-readable error message. + * + * ```json + * { + * "fieldPath": "bar", + * "ruleId": "foo.bar", + * "message": "bar must be greater than 0" + * } + * ``` + * + * @generated from message buf.validate.Violation + */ +export type Violation = Message<"buf.validate.Violation"> & { + /** + * `field` is a machine-readable path to the field that failed validation. + * This could be a nested field, in which case the path will include all the parent fields leading to the actual field that caused the violation. + * + * For example, consider the following message: + * + * ```proto + * message Message { + * bool a = 1 [(buf.validate.field).required = true]; + * } + * ``` + * + * It could produce the following violation: + * + * ```textproto + * violation { + * field { element { field_number: 1, field_name: "a", field_type: 8 } } + * ... + * } + * ``` + * + * @generated from field: optional buf.validate.FieldPath field = 5; + */ + field?: FieldPath; + + /** + * `rule` is a machine-readable path that points to the specific rule rule that failed validation. + * This will be a nested field starting from the FieldRules of the field that failed validation. + * For custom rules, this will provide the path of the rule, e.g. `cel[0]`. + * + * For example, consider the following message: + * + * ```proto + * message Message { + * bool a = 1 [(buf.validate.field).required = true]; + * bool b = 2 [(buf.validate.field).cel = { + * id: "custom_rule", + * expression: "!this ? 'b must be true': ''" + * }] + * } + * ``` + * + * It could produce the following violations: + * + * ```textproto + * violation { + * rule { element { field_number: 25, field_name: "required", field_type: 8 } } + * ... + * } + * violation { + * rule { element { field_number: 23, field_name: "cel", field_type: 11, index: 0 } } + * ... + * } + * ``` + * + * @generated from field: optional buf.validate.FieldPath rule = 6; + */ + rule?: FieldPath; + + /** + * `rule_id` is the unique identifier of the `Rule` that was not fulfilled. + * This is the same `id` that was specified in the `Rule` message, allowing easy tracing of which rule was violated. + * + * @generated from field: optional string rule_id = 2; + */ + ruleId: string; + + /** + * `message` is a human-readable error message that describes the nature of the violation. + * This can be the default error message from the violated `Rule`, or it can be a custom message that gives more context about the violation. + * + * @generated from field: optional string message = 3; + */ + message: string; + + /** + * `for_key` indicates whether the violation was caused by a map key, rather than a value. + * + * @generated from field: optional bool for_key = 4; + */ + forKey: boolean; +}; + +/** + * Describes the message buf.validate.Violation. + * Use `create(ViolationSchema)` to create a new message. + */ +export const ViolationSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 27); + +/** + * `FieldPath` provides a path to a nested protobuf field. + * + * This message provides enough information to render a dotted field path even without protobuf descriptors. + * It also provides enough information to resolve a nested field through unknown wire data. + * + * @generated from message buf.validate.FieldPath + */ +export type FieldPath = Message<"buf.validate.FieldPath"> & { + /** + * `elements` contains each element of the path, starting from the root and recursing downward. + * + * @generated from field: repeated buf.validate.FieldPathElement elements = 1; + */ + elements: FieldPathElement[]; +}; + +/** + * Describes the message buf.validate.FieldPath. + * Use `create(FieldPathSchema)` to create a new message. + */ +export const FieldPathSchema: GenMessage = /*@__PURE__*/ messageDesc(file_buf_validate_validate, 28); + +/** + * `FieldPathElement` provides enough information to nest through a single protobuf field. + * + * If the selected field is a map or repeated field, the `subscript` value selects a specific element from it. + * A path that refers to a value nested under a map key or repeated field index will have a `subscript` value. + * The `field_type` field allows unambiguous resolution of a field even if descriptors are not available. + * + * @generated from message buf.validate.FieldPathElement + */ +export type FieldPathElement = Message<"buf.validate.FieldPathElement"> & { + /** + * `field_number` is the field number this path element refers to. + * + * @generated from field: optional int32 field_number = 1; + */ + fieldNumber: number; + + /** + * `field_name` contains the field name this path element refers to. + * This can be used to display a human-readable path even if the field number is unknown. + * + * @generated from field: optional string field_name = 2; + */ + fieldName: string; + + /** + * `field_type` specifies the type of this field. When using reflection, this value is not needed. + * + * This value is provided to make it possible to traverse unknown fields through wire data. + * When traversing wire data, be mindful of both packed[1] and delimited[2] encoding schemes. + * + * [1]: https://protobuf.dev/programming-guides/encoding/#packed + * [2]: https://protobuf.dev/programming-guides/encoding/#groups + * + * N.B.: Although groups are deprecated, the corresponding delimited encoding scheme is not, and + * can be explicitly used in Protocol Buffers 2023 Edition. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type field_type = 3; + */ + fieldType: FieldDescriptorProto_Type; + + /** + * `key_type` specifies the map key type of this field. This value is useful when traversing + * unknown fields through wire data: specifically, it allows handling the differences between + * different integer encodings. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type key_type = 4; + */ + keyType: FieldDescriptorProto_Type; + + /** + * `value_type` specifies map value type of this field. This is useful if you want to display a + * value inside unknown fields through wire data. + * + * @generated from field: optional google.protobuf.FieldDescriptorProto.Type value_type = 5; + */ + valueType: FieldDescriptorProto_Type; + + /** + * `subscript` contains a repeated index or map key, if this path element nests into a repeated or map field. + * + * @generated from oneof buf.validate.FieldPathElement.subscript + */ + subscript: + | { + /** + * `index` specifies a 0-based index into a repeated field. + * + * @generated from field: uint64 index = 6; + */ + value: bigint; + case: "index"; + } + | { + /** + * `bool_key` specifies a map key of type bool. + * + * @generated from field: bool bool_key = 7; + */ + value: boolean; + case: "boolKey"; + } + | { + /** + * `int_key` specifies a map key of type int32, int64, sint32, sint64, sfixed32 or sfixed64. + * + * @generated from field: int64 int_key = 8; + */ + value: bigint; + case: "intKey"; + } + | { + /** + * `uint_key` specifies a map key of type uint32, uint64, fixed32 or fixed64. + * + * @generated from field: uint64 uint_key = 9; + */ + value: bigint; + case: "uintKey"; + } + | { + /** + * `string_key` specifies a map key of type string. + * + * @generated from field: string string_key = 10; + */ + value: string; + case: "stringKey"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message buf.validate.FieldPathElement. + * Use `create(FieldPathElementSchema)` to create a new message. + */ +export const FieldPathElementSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_buf_validate_validate, 29); + +/** + * Specifies how FieldRules.ignore behaves. See the documentation for + * FieldRules.required for definitions of "populated" and "nullable". + * + * @generated from enum buf.validate.Ignore + */ +export enum Ignore { + /** + * Validation is only skipped if it's an unpopulated nullable fields. + * + * ```proto + * syntax="proto3"; + * + * message Request { + * // The uri rule applies to any value, including the empty string. + * string foo = 1 [ + * (buf.validate.field).string.uri = true + * ]; + * + * // The uri rule only applies if the field is set, including if it's + * // set to the empty string. + * optional string bar = 2 [ + * (buf.validate.field).string.uri = true + * ]; + * + * // The min_items rule always applies, even if the list is empty. + * repeated string baz = 3 [ + * (buf.validate.field).repeated.min_items = 3 + * ]; + * + * // The custom CEL rule applies only if the field is set, including if + * // it's the "zero" value of that message. + * SomeMessage quux = 4 [ + * (buf.validate.field).cel = {/* ... *\/} + * ]; + * } + * ``` + * + * @generated from enum value: IGNORE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Validation is skipped if the field is unpopulated. This rule is redundant + * if the field is already nullable. + * + * ```proto + * syntax="proto3 + * + * message Request { + * // The uri rule applies only if the value is not the empty string. + * string foo = 1 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * + * // IGNORE_IF_UNPOPULATED is equivalent to IGNORE_UNSPECIFIED in this + * // case: the uri rule only applies if the field is set, including if + * // it's set to the empty string. + * optional string bar = 2 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * + * // The min_items rule only applies if the list has at least one item. + * repeated string baz = 3 [ + * (buf.validate.field).repeated.min_items = 3, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * + * // IGNORE_IF_UNPOPULATED is equivalent to IGNORE_UNSPECIFIED in this + * // case: the custom CEL rule applies only if the field is set, including + * // if it's the "zero" value of that message. + * SomeMessage quux = 4 [ + * (buf.validate.field).cel = {/* ... *\/}, + * (buf.validate.field).ignore = IGNORE_IF_UNPOPULATED + * ]; + * } + * ``` + * + * @generated from enum value: IGNORE_IF_UNPOPULATED = 1; + */ + IF_UNPOPULATED = 1, + + /** + * Validation is skipped if the field is unpopulated or if it is a nullable + * field populated with its default value. This is typically the zero or + * empty value, but proto2 scalars support custom defaults. For messages, the + * default is a non-null message with all its fields unpopulated. + * + * ```proto + * syntax="proto3 + * + * message Request { + * // IGNORE_IF_DEFAULT_VALUE is equivalent to IGNORE_IF_UNPOPULATED in + * // this case; the uri rule applies only if the value is not the empty + * // string. + * string foo = 1 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * + * // The uri rule only applies if the field is set to a value other than + * // the empty string. + * optional string bar = 2 [ + * (buf.validate.field).string.uri = true, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * + * // IGNORE_IF_DEFAULT_VALUE is equivalent to IGNORE_IF_UNPOPULATED in + * // this case; the min_items rule only applies if the list has at least + * // one item. + * repeated string baz = 3 [ + * (buf.validate.field).repeated.min_items = 3, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * + * // The custom CEL rule only applies if the field is set to a value other + * // than an empty message (i.e., fields are unpopulated). + * SomeMessage quux = 4 [ + * (buf.validate.field).cel = {/* ... *\/}, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * } + * ``` + * + * This rule is affected by proto2 custom default values: + * + * ```proto + * syntax="proto2"; + * + * message Request { + * // The gt rule only applies if the field is set and it's value is not + * the default (i.e., not -42). The rule even applies if the field is set + * to zero since the default value differs. + * optional int32 value = 1 [ + * default = -42, + * (buf.validate.field).int32.gt = 0, + * (buf.validate.field).ignore = IGNORE_IF_DEFAULT_VALUE + * ]; + * } + * + * @generated from enum value: IGNORE_IF_DEFAULT_VALUE = 2; + */ + IF_DEFAULT_VALUE = 2, + + /** + * The validation rules of this field will be skipped and not evaluated. This + * is useful for situations that necessitate turning off the rules of a field + * containing a message that may not make sense in the current context, or to + * temporarily disable rules during development. + * + * ```proto + * message MyMessage { + * // The field's rules will always be ignored, including any validation's + * // on value's fields. + * MyOtherMessage value = 1 [ + * (buf.validate.field).ignore = IGNORE_ALWAYS]; + * } + * ``` + * + * @generated from enum value: IGNORE_ALWAYS = 3; + */ + ALWAYS = 3, +} + +/** + * Describes the enum buf.validate.Ignore. + */ +export const IgnoreSchema: GenEnum = /*@__PURE__*/ enumDesc(file_buf_validate_validate, 0); + +/** + * WellKnownRegex contain some well-known patterns. + * + * @generated from enum buf.validate.KnownRegex + */ +export enum KnownRegex { + /** + * @generated from enum value: KNOWN_REGEX_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * HTTP header name as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2). + * + * @generated from enum value: KNOWN_REGEX_HTTP_HEADER_NAME = 1; + */ + HTTP_HEADER_NAME = 1, + + /** + * HTTP header value as defined by [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4). + * + * @generated from enum value: KNOWN_REGEX_HTTP_HEADER_VALUE = 2; + */ + HTTP_HEADER_VALUE = 2, +} + +/** + * Describes the enum buf.validate.KnownRegex. + */ +export const KnownRegexSchema: GenEnum = /*@__PURE__*/ enumDesc(file_buf_validate_validate, 1); + +/** + * Rules specify the validations to be performed on this message. By default, + * no validation is performed against a message. + * + * @generated from extension: optional buf.validate.MessageRules message = 1159; + */ +export const message: GenExtension = /*@__PURE__*/ extDesc(file_buf_validate_validate, 0); + +/** + * Rules specify the validations to be performed on this oneof. By default, + * no validation is performed against a oneof. + * + * @generated from extension: optional buf.validate.OneofRules oneof = 1159; + */ +export const oneof: GenExtension = /*@__PURE__*/ extDesc(file_buf_validate_validate, 1); + +/** + * Rules specify the validations to be performed on this field. By default, + * no validation is performed against a field. + * + * @generated from extension: optional buf.validate.FieldRules field = 1159; + */ +export const field: GenExtension = /*@__PURE__*/ extDesc(file_buf_validate_validate, 2); + +/** + * Specifies predefined rules. When extending a standard rule message, + * this adds additional CEL expressions that apply when the extension is used. + * + * ```proto + * extend buf.validate.Int32Rules { + * bool is_zero [(buf.validate.predefined).cel = { + * id: "int32.is_zero", + * message: "value must be zero", + * expression: "!rule || this == 0", + * }]; + * } + * + * message Foo { + * int32 reserved = 1 [(buf.validate.field).int32.(is_zero) = true]; + * } + * ``` + * + * @generated from extension: optional buf.validate.PredefinedRules predefined = 1160; + */ +export const predefined: GenExtension = + /*@__PURE__*/ + extDesc(file_buf_validate_validate, 3); diff --git a/client/src/protoFleet/api/generated/capabilities/v1/capabilities_pb.ts b/client/src/protoFleet/api/generated/capabilities/v1/capabilities_pb.ts new file mode 100644 index 000000000..bbcc28a57 --- /dev/null +++ b/client/src/protoFleet/api/generated/capabilities/v1/capabilities_pb.ts @@ -0,0 +1,335 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file capabilities/v1/capabilities.proto (package capabilities.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file capabilities/v1/capabilities.proto. + */ +export const file_capabilities_v1_capabilities: GenFile = + /*@__PURE__*/ + fileDesc( + "CiJjYXBhYmlsaXRpZXMvdjEvY2FwYWJpbGl0aWVzLnByb3RvEg9jYXBhYmlsaXRpZXMudjEixQIKEU1pbmVyQ2FwYWJpbGl0aWVzEh8KDG1hbnVmYWN0dXJlchgBIAEoCUIJukgGcgQQARhkEksKDmF1dGhlbnRpY2F0aW9uGAIgASgLMisuY2FwYWJpbGl0aWVzLnYxLkF1dGhlbnRpY2F0aW9uQ2FwYWJpbGl0aWVzQga6SAPIAQESPgoIY29tbWFuZHMYAyABKAsyJC5jYXBhYmlsaXRpZXMudjEuQ29tbWFuZENhcGFiaWxpdGllc0IGukgDyAEBEkEKCXRlbGVtZXRyeRgEIAEoCzImLmNhcGFiaWxpdGllcy52MS5UZWxlbWV0cnlDYXBhYmlsaXRpZXNCBrpIA8gBARI/CghmaXJtd2FyZRgFIAEoCzIlLmNhcGFiaWxpdGllcy52MS5GaXJtd2FyZUNhcGFiaWxpdGllc0IGukgDyAEBIm8KGkF1dGhlbnRpY2F0aW9uQ2FwYWJpbGl0aWVzElEKEXN1cHBvcnRlZF9tZXRob2RzGAEgAygOMiUuY2FwYWJpbGl0aWVzLnYxLkF1dGhlbnRpY2F0aW9uTWV0aG9kQg+6SAySAQkIASIFggECIAAivgMKE0NvbW1hbmRDYXBhYmlsaXRpZXMSGAoQcmVib290X3N1cHBvcnRlZBgBIAEoCBIeChZtaW5pbmdfc3RhcnRfc3VwcG9ydGVkGAIgASgIEh0KFW1pbmluZ19zdG9wX3N1cHBvcnRlZBgDIAEoCBIbChNsZWRfYmxpbmtfc3VwcG9ydGVkGAQgASgIEh8KF2ZhY3RvcnlfcmVzZXRfc3VwcG9ydGVkGAUgASgIEh0KFWFpcl9jb29saW5nX3N1cHBvcnRlZBgGIAEoCBIjChtpbW1lcnNpb25fY29vbGluZ19zdXBwb3J0ZWQYByABKAgSIAoYcG9vbF9zd2l0Y2hpbmdfc3VwcG9ydGVkGAggASgIEhYKDnBvb2xfbWF4X2NvdW50GAkgASgFEh8KF3Bvb2xfcHJpb3JpdHlfc3VwcG9ydGVkGAogASgIEh8KF2xvZ3NfZG93bmxvYWRfc3VwcG9ydGVkGAsgASgIEicKH3Bvd2VyX21vZGVfZWZmaWNpZW5jeV9zdXBwb3J0ZWQYDCABKAgSJwofdXBkYXRlX21pbmVyX3Bhc3N3b3JkX3N1cHBvcnRlZBgNIAEoCCLCAwoVVGVsZW1ldHJ5Q2FwYWJpbGl0aWVzEiQKHHJlYWx0aW1lX3RlbGVtZXRyeV9zdXBwb3J0ZWQYASABKAgSIQoZaGlzdG9yaWNhbF9kYXRhX3N1cHBvcnRlZBgCIAEoCBIZChFoYXNocmF0ZV9yZXBvcnRlZBgDIAEoCBIcChRwb3dlcl91c2FnZV9yZXBvcnRlZBgEIAEoCBIcChR0ZW1wZXJhdHVyZV9yZXBvcnRlZBgFIAEoCBIaChJmYW5fc3BlZWRfcmVwb3J0ZWQYBiABKAgSGwoTZWZmaWNpZW5jeV9yZXBvcnRlZBgHIAEoCBIXCg91cHRpbWVfcmVwb3J0ZWQYCCABKAgSHAoUZXJyb3JfY291bnRfcmVwb3J0ZWQYCSABKAgSHQoVbWluZXJfc3RhdHVzX3JlcG9ydGVkGAogASgIEhsKE3Bvb2xfc3RhdHNfcmVwb3J0ZWQYCyABKAgSHwoXcGVyX2NoaXBfc3RhdHNfcmVwb3J0ZWQYDCABKAgSIAoYcGVyX2JvYXJkX3N0YXRzX3JlcG9ydGVkGA0gASgIEhoKEnBzdV9zdGF0c19yZXBvcnRlZBgOIAEoCCJVChRGaXJtd2FyZUNhcGFiaWxpdGllcxIcChRvdGFfdXBkYXRlX3N1cHBvcnRlZBgBIAEoCBIfChdtYW51YWxfdXBsb2FkX3N1cHBvcnRlZBgCIAEoCCqIAQoUQXV0aGVudGljYXRpb25NZXRob2QSJQohQVVUSEVOVElDQVRJT05fTUVUSE9EX1VOU1BFQ0lGSUVEEAASHwobQVVUSEVOVElDQVRJT05fTUVUSE9EX0JBU0lDEAESKAokQVVUSEVOVElDQVRJT05fTUVUSE9EX0FTWU1NRVRSSUNfS0VZEAJC2AEKE2NvbS5jYXBhYmlsaXRpZXMudjFCEUNhcGFiaWxpdGllc1Byb3RvUAFaUWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2NhcGFiaWxpdGllcy92MTtjYXBhYmlsaXRpZXN2MaICA0NYWKoCD0NhcGFiaWxpdGllcy5WMcoCD0NhcGFiaWxpdGllc1xWMeICG0NhcGFiaWxpdGllc1xWMVxHUEJNZXRhZGF0YeoCEENhcGFiaWxpdGllczo6VjFiBnByb3RvMw", + [file_buf_validate_validate], + ); + +/** + * MinerCapabilities defines what operations and features a specific miner model supports + * + * @generated from message capabilities.v1.MinerCapabilities + */ +export type MinerCapabilities = Message<"capabilities.v1.MinerCapabilities"> & { + /** + * Manufacturer name + * + * @generated from field: string manufacturer = 1; + */ + manufacturer: string; + + /** + * Authentication capabilities + * + * @generated from field: capabilities.v1.AuthenticationCapabilities authentication = 2; + */ + authentication?: AuthenticationCapabilities; + + /** + * Command capabilities + * + * @generated from field: capabilities.v1.CommandCapabilities commands = 3; + */ + commands?: CommandCapabilities; + + /** + * Telemetry capabilities + * + * @generated from field: capabilities.v1.TelemetryCapabilities telemetry = 4; + */ + telemetry?: TelemetryCapabilities; + + /** + * Firmware capabilities + * + * @generated from field: capabilities.v1.FirmwareCapabilities firmware = 5; + */ + firmware?: FirmwareCapabilities; +}; + +/** + * Describes the message capabilities.v1.MinerCapabilities. + * Use `create(MinerCapabilitiesSchema)` to create a new message. + */ +export const MinerCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 0); + +/** + * Authentication methods supported by the miner + * + * @generated from message capabilities.v1.AuthenticationCapabilities + */ +export type AuthenticationCapabilities = Message<"capabilities.v1.AuthenticationCapabilities"> & { + /** + * Authentication methods supported by the miner + * + * @generated from field: repeated capabilities.v1.AuthenticationMethod supported_methods = 1; + */ + supportedMethods: AuthenticationMethod[]; +}; + +/** + * Describes the message capabilities.v1.AuthenticationCapabilities. + * Use `create(AuthenticationCapabilitiesSchema)` to create a new message. + */ +export const AuthenticationCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 1); + +/** + * Command operations supported by the miner + * + * @generated from message capabilities.v1.CommandCapabilities + */ +export type CommandCapabilities = Message<"capabilities.v1.CommandCapabilities"> & { + /** + * Basic operations + * + * @generated from field: bool reboot_supported = 1; + */ + rebootSupported: boolean; + + /** + * @generated from field: bool mining_start_supported = 2; + */ + miningStartSupported: boolean; + + /** + * @generated from field: bool mining_stop_supported = 3; + */ + miningStopSupported: boolean; + + /** + * LED operations + * + * @generated from field: bool led_blink_supported = 4; + */ + ledBlinkSupported: boolean; + + /** + * Advanced operations + * + * @generated from field: bool factory_reset_supported = 5; + */ + factoryResetSupported: boolean; + + /** + * Cooling control + * + * @generated from field: bool air_cooling_supported = 6; + */ + airCoolingSupported: boolean; + + /** + * @generated from field: bool immersion_cooling_supported = 7; + */ + immersionCoolingSupported: boolean; + + /** + * Pool management + * + * @generated from field: bool pool_switching_supported = 8; + */ + poolSwitchingSupported: boolean; + + /** + * @generated from field: int32 pool_max_count = 9; + */ + poolMaxCount: number; + + /** + * @generated from field: bool pool_priority_supported = 10; + */ + poolPrioritySupported: boolean; + + /** + * Log management + * + * @generated from field: bool logs_download_supported = 11; + */ + logsDownloadSupported: boolean; + + /** + * Power mode control + * + * @generated from field: bool power_mode_efficiency_supported = 12; + */ + powerModeEfficiencySupported: boolean; + + /** + * Security management + * + * @generated from field: bool update_miner_password_supported = 13; + */ + updateMinerPasswordSupported: boolean; +}; + +/** + * Describes the message capabilities.v1.CommandCapabilities. + * Use `create(CommandCapabilitiesSchema)` to create a new message. + */ +export const CommandCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 2); + +/** + * Telemetry data capabilities + * + * @generated from message capabilities.v1.TelemetryCapabilities + */ +export type TelemetryCapabilities = Message<"capabilities.v1.TelemetryCapabilities"> & { + /** + * Real-time telemetry + * + * @generated from field: bool realtime_telemetry_supported = 1; + */ + realtimeTelemetrySupported: boolean; + + /** + * Historical data + * + * @generated from field: bool historical_data_supported = 2; + */ + historicalDataSupported: boolean; + + /** + * Supported metrics + * + * @generated from field: bool hashrate_reported = 3; + */ + hashrateReported: boolean; + + /** + * @generated from field: bool power_usage_reported = 4; + */ + powerUsageReported: boolean; + + /** + * @generated from field: bool temperature_reported = 5; + */ + temperatureReported: boolean; + + /** + * @generated from field: bool fan_speed_reported = 6; + */ + fanSpeedReported: boolean; + + /** + * @generated from field: bool efficiency_reported = 7; + */ + efficiencyReported: boolean; + + /** + * @generated from field: bool uptime_reported = 8; + */ + uptimeReported: boolean; + + /** + * @generated from field: bool error_count_reported = 9; + */ + errorCountReported: boolean; + + /** + * @generated from field: bool miner_status_reported = 10; + */ + minerStatusReported: boolean; + + /** + * @generated from field: bool pool_stats_reported = 11; + */ + poolStatsReported: boolean; + + /** + * Component-level telemetry + * + * @generated from field: bool per_chip_stats_reported = 12; + */ + perChipStatsReported: boolean; + + /** + * @generated from field: bool per_board_stats_reported = 13; + */ + perBoardStatsReported: boolean; + + /** + * @generated from field: bool psu_stats_reported = 14; + */ + psuStatsReported: boolean; +}; + +/** + * Describes the message capabilities.v1.TelemetryCapabilities. + * Use `create(TelemetryCapabilitiesSchema)` to create a new message. + */ +export const TelemetryCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 3); + +/** + * Firmware update capabilities + * + * @generated from message capabilities.v1.FirmwareCapabilities + */ +export type FirmwareCapabilities = Message<"capabilities.v1.FirmwareCapabilities"> & { + /** + * Update methods + * + * @generated from field: bool ota_update_supported = 1; + */ + otaUpdateSupported: boolean; + + /** + * @generated from field: bool manual_upload_supported = 2; + */ + manualUploadSupported: boolean; +}; + +/** + * Describes the message capabilities.v1.FirmwareCapabilities. + * Use `create(FirmwareCapabilitiesSchema)` to create a new message. + */ +export const FirmwareCapabilitiesSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_capabilities_v1_capabilities, 4); + +/** + * Authentication method types + * + * @generated from enum capabilities.v1.AuthenticationMethod + */ +export enum AuthenticationMethod { + /** + * @generated from enum value: AUTHENTICATION_METHOD_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AUTHENTICATION_METHOD_BASIC = 1; + */ + BASIC = 1, + + /** + * @generated from enum value: AUTHENTICATION_METHOD_ASYMMETRIC_KEY = 2; + */ + ASYMMETRIC_KEY = 2, +} + +/** + * Describes the enum capabilities.v1.AuthenticationMethod. + */ +export const AuthenticationMethodSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_capabilities_v1_capabilities, 0); diff --git a/client/src/protoFleet/api/generated/collection/v1/collection_pb.ts b/client/src/protoFleet/api/generated/collection/v1/collection_pb.ts new file mode 100644 index 000000000..4eaf522de --- /dev/null +++ b/client/src/protoFleet/api/generated/collection/v1/collection_pb.ts @@ -0,0 +1,1771 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file collection/v1/collection.proto (package collection.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { DeviceSelector } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { SortConfig } from "../../common/v1/sort_pb"; +import { file_common_v1_sort } from "../../common/v1/sort_pb"; +import type { ComponentType } from "../../errors/v1/errors_pb"; +import { file_errors_v1_errors } from "../../errors/v1/errors_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file collection/v1/collection.proto. + */ +export const file_collection_v1_collection: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch5jb2xsZWN0aW9uL3YxL2NvbGxlY3Rpb24ucHJvdG8SDWNvbGxlY3Rpb24udjEi0wIKEERldmljZUNvbGxlY3Rpb24SCgoCaWQYASABKAMSKwoEdHlwZRgCIAEoDjIdLmNvbGxlY3Rpb24udjEuQ29sbGVjdGlvblR5cGUSDQoFbGFiZWwYAyABKAkSEwoLZGVzY3JpcHRpb24YBCABKAkSFAoMZGV2aWNlX2NvdW50GAUgASgFEi4KCmNyZWF0ZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEiwKCXJhY2tfaW5mbxgIIAEoCzIXLmNvbGxlY3Rpb24udjEuUmFja0luZm9IABIuCgpncm91cF9pbmZvGAkgASgLMhguY29sbGVjdGlvbi52MS5Hcm91cEluZm9IAEIOCgx0eXBlX2RldGFpbHMivAEKCFJhY2tJbmZvEhUKBHJvd3MYASABKAVCB7pIBBoCIAASGAoHY29sdW1ucxgCIAEoBUIHukgEGgIgABIVCgR6b25lGAMgASgJQge6SARyAhABEjIKC29yZGVyX2luZGV4GAQgASgOMh0uY29sbGVjdGlvbi52MS5SYWNrT3JkZXJJbmRleBI0Cgxjb29saW5nX3R5cGUYBSABKA4yHi5jb2xsZWN0aW9uLnYxLlJhY2tDb29saW5nVHlwZSILCglHcm91cEluZm8inwEKEENvbGxlY3Rpb25NZW1iZXISGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSLAoIYWRkZWRfYXQYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKBHJhY2sYAyABKAsyIC5jb2xsZWN0aW9uLnYxLlJhY2tNZW1iZXJEZXRhaWxzSABCEAoObWVtYmVyX2RldGFpbHMiSwoRUmFja01lbWJlckRldGFpbHMSNgoNc2xvdF9wb3NpdGlvbhgBIAEoCzIfLmNvbGxlY3Rpb24udjEuUmFja1Nsb3RQb3NpdGlvbiJBChBSYWNrU2xvdFBvc2l0aW9uEhQKA3JvdxgBIAEoBUIHukgEGgIoABIXCgZjb2x1bW4YAiABKAVCB7pIBBoCKAAiyQIKF0NyZWF0ZUNvbGxlY3Rpb25SZXF1ZXN0EjcKBHR5cGUYASABKA4yHS5jb2xsZWN0aW9uLnYxLkNvbGxlY3Rpb25UeXBlQgq6SAeCAQQQASAAEhsKBWxhYmVsGAIgASgJQgy6SAnIAQFyBBABGGQSHQoLZGVzY3JpcHRpb24YAyABKAlCCLpIBXIDGPQDEiwKCXJhY2tfaW5mbxgEIAEoCzIXLmNvbGxlY3Rpb24udjEuUmFja0luZm9IABIuCgpncm91cF9pbmZvGAUgASgLMhguY29sbGVjdGlvbi52MS5Hcm91cEluZm9IABI3Cg9kZXZpY2Vfc2VsZWN0b3IYBiABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JIAYgBAUIOCgx0eXBlX2RldGFpbHNCEgoQX2RldmljZV9zZWxlY3RvciJkChhDcmVhdGVDb2xsZWN0aW9uUmVzcG9uc2USMwoKY29sbGVjdGlvbhgBIAEoCzIfLmNvbGxlY3Rpb24udjEuRGV2aWNlQ29sbGVjdGlvbhITCgthZGRlZF9jb3VudBgCIAEoBSI2ChRHZXRDb2xsZWN0aW9uUmVxdWVzdBIeCg1jb2xsZWN0aW9uX2lkGAEgASgDQge6SAQiAiAAIkwKFUdldENvbGxlY3Rpb25SZXNwb25zZRIzCgpjb2xsZWN0aW9uGAEgASgLMh8uY29sbGVjdGlvbi52MS5EZXZpY2VDb2xsZWN0aW9uIr4CChdVcGRhdGVDb2xsZWN0aW9uUmVxdWVzdBIeCg1jb2xsZWN0aW9uX2lkGAEgASgDQge6SAQiAiAAEiAKBWxhYmVsGAIgASgJQgy6SAnYAQFyBBABGGRIAYgBARIlCgtkZXNjcmlwdGlvbhgDIAEoCUILukgI2AEBcgMY9ANIAogBARIsCglyYWNrX2luZm8YBCABKAsyFy5jb2xsZWN0aW9uLnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgFIAEoCzIYLmNvbGxlY3Rpb24udjEuR3JvdXBJbmZvSAASMgoPZGV2aWNlX3NlbGVjdG9yGAYgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQg4KDHR5cGVfZGV0YWlsc0IICgZfbGFiZWxCDgoMX2Rlc2NyaXB0aW9uIk8KGFVwZGF0ZUNvbGxlY3Rpb25SZXNwb25zZRIzCgpjb2xsZWN0aW9uGAEgASgLMh8uY29sbGVjdGlvbi52MS5EZXZpY2VDb2xsZWN0aW9uIjkKF0RlbGV0ZUNvbGxlY3Rpb25SZXF1ZXN0Eh4KDWNvbGxlY3Rpb25faWQYASABKANCB7pIBCICIAAiGgoYRGVsZXRlQ29sbGVjdGlvblJlc3BvbnNlIuwBChZMaXN0Q29sbGVjdGlvbnNSZXF1ZXN0EjUKBHR5cGUYASABKA4yHS5jb2xsZWN0aW9uLnYxLkNvbGxlY3Rpb25UeXBlQgi6SAWCAQIQARIaCglwYWdlX3NpemUYAiABKAVCB7pIBBoCKAASEgoKcGFnZV90b2tlbhgDIAEoCRIjCgRzb3J0GAQgASgLMhUuY29tbW9uLnYxLlNvcnRDb25maWcSNwoVZXJyb3JfY29tcG9uZW50X3R5cGVzGAUgAygOMhguZXJyb3JzLnYxLkNvbXBvbmVudFR5cGUSDQoFem9uZXMYBiADKAkifQoXTGlzdENvbGxlY3Rpb25zUmVzcG9uc2USNAoLY29sbGVjdGlvbnMYASADKAsyHy5jb2xsZWN0aW9uLnYxLkRldmljZUNvbGxlY3Rpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhMKC3RvdGFsX2NvdW50GAMgASgFInsKHUFkZERldmljZXNUb0NvbGxlY3Rpb25SZXF1ZXN0Eh4KDWNvbGxlY3Rpb25faWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiTAoeQWRkRGV2aWNlc1RvQ29sbGVjdGlvblJlc3BvbnNlEhUKDWNvbGxlY3Rpb25faWQYASABKAMSEwoLYWRkZWRfY291bnQYAiABKAUigAEKIlJlbW92ZURldmljZXNGcm9tQ29sbGVjdGlvblJlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABI6Cg9kZXZpY2Vfc2VsZWN0b3IYAiABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBASI8CiNSZW1vdmVEZXZpY2VzRnJvbUNvbGxlY3Rpb25SZXNwb25zZRIVCg1yZW1vdmVkX2NvdW50GAEgASgFIm4KHExpc3RDb2xsZWN0aW9uTWVtYmVyc1JlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABIaCglwYWdlX3NpemUYAiABKAVCB7pIBBoCKAASEgoKcGFnZV90b2tlbhgDIAEoCSJqCh1MaXN0Q29sbGVjdGlvbk1lbWJlcnNSZXNwb25zZRIwCgdtZW1iZXJzGAEgAygLMh8uY29sbGVjdGlvbi52MS5Db2xsZWN0aW9uTWVtYmVyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSJuChtHZXREZXZpY2VDb2xsZWN0aW9uc1JlcXVlc3QSIgoRZGV2aWNlX2lkZW50aWZpZXIYASABKAlCB7pIBHICEAESKwoEdHlwZRgCIAEoDjIdLmNvbGxlY3Rpb24udjEuQ29sbGVjdGlvblR5cGUiVAocR2V0RGV2aWNlQ29sbGVjdGlvbnNSZXNwb25zZRI0Cgtjb2xsZWN0aW9ucxgBIAMoCzIfLmNvbGxlY3Rpb24udjEuRGV2aWNlQ29sbGVjdGlvbiKbAQoaU2V0UmFja1Nsb3RQb3NpdGlvblJlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABIiChFkZXZpY2VfaWRlbnRpZmllchgCIAEoCUIHukgEcgIQARI5Cghwb3NpdGlvbhgDIAEoCzIfLmNvbGxlY3Rpb24udjEuUmFja1Nsb3RQb3NpdGlvbkIGukgDyAEBIlsKG1NldFJhY2tTbG90UG9zaXRpb25SZXNwb25zZRIVCg1jb2xsZWN0aW9uX2lkGAEgASgDEiUKBHNsb3QYAiABKAsyFy5jb2xsZWN0aW9uLnYxLlJhY2tTbG90ImIKHENsZWFyUmFja1Nsb3RQb3NpdGlvblJlcXVlc3QSHgoNY29sbGVjdGlvbl9pZBgBIAEoA0IHukgEIgIgABIiChFkZXZpY2VfaWRlbnRpZmllchgCIAEoCUIHukgEcgIQASIfCh1DbGVhclJhY2tTbG90UG9zaXRpb25SZXNwb25zZSI1ChNHZXRSYWNrU2xvdHNSZXF1ZXN0Eh4KDWNvbGxlY3Rpb25faWQYASABKANCB7pIBCICIAAiWAoIUmFja1Nsb3QSGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSMQoIcG9zaXRpb24YAiABKAsyHy5jb2xsZWN0aW9uLnYxLlJhY2tTbG90UG9zaXRpb24iPgoUR2V0UmFja1Nsb3RzUmVzcG9uc2USJgoFc2xvdHMYASADKAsyFy5jb2xsZWN0aW9uLnYxLlJhY2tTbG90Iu4ECg9Db2xsZWN0aW9uU3RhdHMSFQoNY29sbGVjdGlvbl9pZBgBIAEoAxIUCgxkZXZpY2VfY291bnQYAiABKAUSFwoPcmVwb3J0aW5nX2NvdW50GAMgASgFEhoKEnRvdGFsX2hhc2hyYXRlX3RocxgEIAEoARIaChJhdmdfZWZmaWNpZW5jeV9qdGgYBSABKAESFgoOdG90YWxfcG93ZXJfa3cYBiABKAESGQoRbWluX3RlbXBlcmF0dXJlX2MYByABKAESGQoRbWF4X3RlbXBlcmF0dXJlX2MYCCABKAESFQoNaGFzaGluZ19jb3VudBgJIAEoBRIUCgxicm9rZW5fY291bnQYCiABKAUSFQoNb2ZmbGluZV9jb3VudBgLIAEoBRIWCg5zbGVlcGluZ19jb3VudBgMIAEoBRIgChhoYXNocmF0ZV9yZXBvcnRpbmdfY291bnQYDSABKAUSIgoaZWZmaWNpZW5jeV9yZXBvcnRpbmdfY291bnQYDiABKAUSHQoVcG93ZXJfcmVwb3J0aW5nX2NvdW50GA8gASgFEiMKG3RlbXBlcmF0dXJlX3JlcG9ydGluZ19jb3VudBgQIAEoBRIhChljb250cm9sX2JvYXJkX2lzc3VlX2NvdW50GBEgASgFEhcKD2Zhbl9pc3N1ZV9jb3VudBgSIAEoBRIeChZoYXNoX2JvYXJkX2lzc3VlX2NvdW50GBMgASgFEhcKD3BzdV9pc3N1ZV9jb3VudBgUIAEoBRI0Cg1zbG90X3N0YXR1c2VzGBUgAygLMh0uY29sbGVjdGlvbi52MS5SYWNrU2xvdFN0YXR1cyIzChlHZXRDb2xsZWN0aW9uU3RhdHNSZXF1ZXN0EhYKDmNvbGxlY3Rpb25faWRzGAEgAygDIksKGkdldENvbGxlY3Rpb25TdGF0c1Jlc3BvbnNlEi0KBXN0YXRzGAEgAygLMh4uY29sbGVjdGlvbi52MS5Db2xsZWN0aW9uU3RhdHMiXgoOUmFja1Nsb3RTdGF0dXMSCwoDcm93GAEgASgFEg4KBmNvbHVtbhgCIAEoBRIvCgZzdGF0dXMYAyABKA4yHy5jb2xsZWN0aW9uLnYxLlNsb3REZXZpY2VTdGF0dXMiFgoUTGlzdFJhY2tab25lc1JlcXVlc3QiJgoVTGlzdFJhY2tab25lc1Jlc3BvbnNlEg0KBXpvbmVzGAEgAygJIhYKFExpc3RSYWNrVHlwZXNSZXF1ZXN0Ij0KCFJhY2tUeXBlEgwKBHJvd3MYASABKAUSDwoHY29sdW1ucxgCIAEoBRISCgpyYWNrX2NvdW50GAMgASgFIkQKFUxpc3RSYWNrVHlwZXNSZXNwb25zZRIrCgpyYWNrX3R5cGVzGAEgAygLMhcuY29sbGVjdGlvbi52MS5SYWNrVHlwZSKLAgoPU2F2ZVJhY2tSZXF1ZXN0EiYKDWNvbGxlY3Rpb25faWQYASABKANCCrpIB9gBASICIABIAIgBARIbCgVsYWJlbBgCIAEoCUIMukgJyAEBcgQQARhkEjIKCXJhY2tfaW5mbxgDIAEoCzIXLmNvbGxlY3Rpb24udjEuUmFja0luZm9CBrpIA8gBARI6Cg9kZXZpY2Vfc2VsZWN0b3IYBCABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBARIxChBzbG90X2Fzc2lnbm1lbnRzGAUgAygLMhcuY29sbGVjdGlvbi52MS5SYWNrU2xvdEIQCg5fY29sbGVjdGlvbl9pZCJfChBTYXZlUmFja1Jlc3BvbnNlEjMKCmNvbGxlY3Rpb24YASABKAsyHy5jb2xsZWN0aW9uLnYxLkRldmljZUNvbGxlY3Rpb24SFgoOYXNzaWduZWRfY291bnQYAiABKAUqZgoOQ29sbGVjdGlvblR5cGUSHwobQ09MTEVDVElPTl9UWVBFX1VOU1BFQ0lGSUVEEAASGQoVQ09MTEVDVElPTl9UWVBFX0dST1VQEAESGAoUQ09MTEVDVElPTl9UWVBFX1JBQ0sQAiq2AQoOUmFja09yZGVySW5kZXgSIAocUkFDS19PUkRFUl9JTkRFWF9VTlNQRUNJRklFRBAAEiAKHFJBQ0tfT1JERVJfSU5ERVhfQk9UVE9NX0xFRlQQARIdChlSQUNLX09SREVSX0lOREVYX1RPUF9MRUZUEAISIQodUkFDS19PUkRFUl9JTkRFWF9CT1RUT01fUklHSFQQAxIeChpSQUNLX09SREVSX0lOREVYX1RPUF9SSUdIVBAEKnAKD1JhY2tDb29saW5nVHlwZRIhCh1SQUNLX0NPT0xJTkdfVFlQRV9VTlNQRUNJRklFRBAAEhkKFVJBQ0tfQ09PTElOR19UWVBFX0FJUhABEh8KG1JBQ0tfQ09PTElOR19UWVBFX0lNTUVSU0lPThACKt0BChBTbG90RGV2aWNlU3RhdHVzEiIKHlNMT1RfREVWSUNFX1NUQVRVU19VTlNQRUNJRklFRBAAEhwKGFNMT1RfREVWSUNFX1NUQVRVU19FTVBUWRABEh4KGlNMT1RfREVWSUNFX1NUQVRVU19IRUFMVEhZEAISJgoiU0xPVF9ERVZJQ0VfU1RBVFVTX05FRURTX0FUVEVOVElPThADEh4KGlNMT1RfREVWSUNFX1NUQVRVU19PRkZMSU5FEAQSHwobU0xPVF9ERVZJQ0VfU1RBVFVTX1NMRUVQSU5HEAUylA0KF0RldmljZUNvbGxlY3Rpb25TZXJ2aWNlEmMKEENyZWF0ZUNvbGxlY3Rpb24SJi5jb2xsZWN0aW9uLnYxLkNyZWF0ZUNvbGxlY3Rpb25SZXF1ZXN0GicuY29sbGVjdGlvbi52MS5DcmVhdGVDb2xsZWN0aW9uUmVzcG9uc2USWgoNR2V0Q29sbGVjdGlvbhIjLmNvbGxlY3Rpb24udjEuR2V0Q29sbGVjdGlvblJlcXVlc3QaJC5jb2xsZWN0aW9uLnYxLkdldENvbGxlY3Rpb25SZXNwb25zZRJjChBVcGRhdGVDb2xsZWN0aW9uEiYuY29sbGVjdGlvbi52MS5VcGRhdGVDb2xsZWN0aW9uUmVxdWVzdBonLmNvbGxlY3Rpb24udjEuVXBkYXRlQ29sbGVjdGlvblJlc3BvbnNlEmMKEERlbGV0ZUNvbGxlY3Rpb24SJi5jb2xsZWN0aW9uLnYxLkRlbGV0ZUNvbGxlY3Rpb25SZXF1ZXN0GicuY29sbGVjdGlvbi52MS5EZWxldGVDb2xsZWN0aW9uUmVzcG9uc2USYAoPTGlzdENvbGxlY3Rpb25zEiUuY29sbGVjdGlvbi52MS5MaXN0Q29sbGVjdGlvbnNSZXF1ZXN0GiYuY29sbGVjdGlvbi52MS5MaXN0Q29sbGVjdGlvbnNSZXNwb25zZRJ1ChZBZGREZXZpY2VzVG9Db2xsZWN0aW9uEiwuY29sbGVjdGlvbi52MS5BZGREZXZpY2VzVG9Db2xsZWN0aW9uUmVxdWVzdBotLmNvbGxlY3Rpb24udjEuQWRkRGV2aWNlc1RvQ29sbGVjdGlvblJlc3BvbnNlEoQBChtSZW1vdmVEZXZpY2VzRnJvbUNvbGxlY3Rpb24SMS5jb2xsZWN0aW9uLnYxLlJlbW92ZURldmljZXNGcm9tQ29sbGVjdGlvblJlcXVlc3QaMi5jb2xsZWN0aW9uLnYxLlJlbW92ZURldmljZXNGcm9tQ29sbGVjdGlvblJlc3BvbnNlEnIKFUxpc3RDb2xsZWN0aW9uTWVtYmVycxIrLmNvbGxlY3Rpb24udjEuTGlzdENvbGxlY3Rpb25NZW1iZXJzUmVxdWVzdBosLmNvbGxlY3Rpb24udjEuTGlzdENvbGxlY3Rpb25NZW1iZXJzUmVzcG9uc2USbwoUR2V0RGV2aWNlQ29sbGVjdGlvbnMSKi5jb2xsZWN0aW9uLnYxLkdldERldmljZUNvbGxlY3Rpb25zUmVxdWVzdBorLmNvbGxlY3Rpb24udjEuR2V0RGV2aWNlQ29sbGVjdGlvbnNSZXNwb25zZRJsChNTZXRSYWNrU2xvdFBvc2l0aW9uEikuY29sbGVjdGlvbi52MS5TZXRSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBoqLmNvbGxlY3Rpb24udjEuU2V0UmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlEnIKFUNsZWFyUmFja1Nsb3RQb3NpdGlvbhIrLmNvbGxlY3Rpb24udjEuQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBosLmNvbGxlY3Rpb24udjEuQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVzcG9uc2USVwoMR2V0UmFja1Nsb3RzEiIuY29sbGVjdGlvbi52MS5HZXRSYWNrU2xvdHNSZXF1ZXN0GiMuY29sbGVjdGlvbi52MS5HZXRSYWNrU2xvdHNSZXNwb25zZRJpChJHZXRDb2xsZWN0aW9uU3RhdHMSKC5jb2xsZWN0aW9uLnYxLkdldENvbGxlY3Rpb25TdGF0c1JlcXVlc3QaKS5jb2xsZWN0aW9uLnYxLkdldENvbGxlY3Rpb25TdGF0c1Jlc3BvbnNlEloKDUxpc3RSYWNrWm9uZXMSIy5jb2xsZWN0aW9uLnYxLkxpc3RSYWNrWm9uZXNSZXF1ZXN0GiQuY29sbGVjdGlvbi52MS5MaXN0UmFja1pvbmVzUmVzcG9uc2USWgoNTGlzdFJhY2tUeXBlcxIjLmNvbGxlY3Rpb24udjEuTGlzdFJhY2tUeXBlc1JlcXVlc3QaJC5jb2xsZWN0aW9uLnYxLkxpc3RSYWNrVHlwZXNSZXNwb25zZRJLCghTYXZlUmFjaxIeLmNvbGxlY3Rpb24udjEuU2F2ZVJhY2tSZXF1ZXN0Gh8uY29sbGVjdGlvbi52MS5TYXZlUmFja1Jlc3BvbnNlQsgBChFjb20uY29sbGVjdGlvbi52MUIPQ29sbGVjdGlvblByb3RvUAFaTWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2NvbGxlY3Rpb24vdjE7Y29sbGVjdGlvbnYxogIDQ1hYqgINQ29sbGVjdGlvbi5WMcoCDUNvbGxlY3Rpb25cVjHiAhlDb2xsZWN0aW9uXFYxXEdQQk1ldGFkYXRh6gIOQ29sbGVjdGlvbjo6VjFiBnByb3RvMw", + [ + file_google_protobuf_timestamp, + file_buf_validate_validate, + file_common_v1_device_selector, + file_common_v1_sort, + file_errors_v1_errors, + ], + ); + +/** + * DeviceCollection represents a group or rack of devices + * + * @generated from message collection.v1.DeviceCollection + */ +export type DeviceCollection = Message<"collection.v1.DeviceCollection"> & { + /** + * Unique identifier for the collection + * + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * Type of collection (group or rack) + * + * @generated from field: collection.v1.CollectionType type = 2; + */ + type: CollectionType; + + /** + * Human-readable label for the collection + * + * @generated from field: string label = 3; + */ + label: string; + + /** + * Optional description of the collection's purpose + * + * @generated from field: string description = 4; + */ + description: string; + + /** + * Number of devices in this collection + * + * @generated from field: int32 device_count = 5; + */ + deviceCount: number; + + /** + * When the collection was created + * + * @generated from field: google.protobuf.Timestamp created_at = 6; + */ + createdAt?: Timestamp; + + /** + * When the collection was last updated + * + * @generated from field: google.protobuf.Timestamp updated_at = 7; + */ + updatedAt?: Timestamp; + + /** + * Type-specific metadata (enforces only one detail type is set) + * + * @generated from oneof collection.v1.DeviceCollection.type_details + */ + typeDetails: + | { + /** + * @generated from field: collection.v1.RackInfo rack_info = 8; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: collection.v1.GroupInfo group_info = 9; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message collection.v1.DeviceCollection. + * Use `create(DeviceCollectionSchema)` to create a new message. + */ +export const DeviceCollectionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 0); + +/** + * Rack-specific metadata for rack-type collections + * + * @generated from message collection.v1.RackInfo + */ +export type RackInfo = Message<"collection.v1.RackInfo"> & { + /** + * Number of rows in the rack grid + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns in the rack grid + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Physical zone description (e.g. building, room, area) + * + * @generated from field: string zone = 3; + */ + zone: string; + + /** + * Order index defining where numbering starts + * + * @generated from field: collection.v1.RackOrderIndex order_index = 4; + */ + orderIndex: RackOrderIndex; + + /** + * Cooling type for this rack + * + * @generated from field: collection.v1.RackCoolingType cooling_type = 5; + */ + coolingType: RackCoolingType; +}; + +/** + * Describes the message collection.v1.RackInfo. + * Use `create(RackInfoSchema)` to create a new message. + */ +export const RackInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 1); + +/** + * Group-specific metadata for group-type collections + * Reserved for future group-specific fields (e.g., tags, policies) + * + * @generated from message collection.v1.GroupInfo + */ +export type GroupInfo = Message<"collection.v1.GroupInfo"> & {}; + +/** + * Describes the message collection.v1.GroupInfo. + * Use `create(GroupInfoSchema)` to create a new message. + */ +export const GroupInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 2); + +/** + * CollectionMember represents a device in a collection + * + * @generated from message collection.v1.CollectionMember + */ +export type CollectionMember = Message<"collection.v1.CollectionMember"> & { + /** + * Device identifier of the member + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * When the device was added to the collection + * + * @generated from field: google.protobuf.Timestamp added_at = 2; + */ + addedAt?: Timestamp; + + /** + * Type-specific member details + * + * @generated from oneof collection.v1.CollectionMember.member_details + */ + memberDetails: + | { + /** + * @generated from field: collection.v1.RackMemberDetails rack = 3; + */ + value: RackMemberDetails; + case: "rack"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message collection.v1.CollectionMember. + * Use `create(CollectionMemberSchema)` to create a new message. + */ +export const CollectionMemberSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 3); + +/** + * Rack-specific details for a collection member + * + * @generated from message collection.v1.RackMemberDetails + */ +export type RackMemberDetails = Message<"collection.v1.RackMemberDetails"> & { + /** + * Slot position of the device within the rack + * + * @generated from field: collection.v1.RackSlotPosition slot_position = 1; + */ + slotPosition?: RackSlotPosition; +}; + +/** + * Describes the message collection.v1.RackMemberDetails. + * Use `create(RackMemberDetailsSchema)` to create a new message. + */ +export const RackMemberDetailsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 4); + +/** + * Position of a device within a rack + * + * @generated from message collection.v1.RackSlotPosition + */ +export type RackSlotPosition = Message<"collection.v1.RackSlotPosition"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; +}; + +/** + * Describes the message collection.v1.RackSlotPosition. + * Use `create(RackSlotPositionSchema)` to create a new message. + */ +export const RackSlotPositionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 5); + +/** + * Request to create a new collection + * + * @generated from message collection.v1.CreateCollectionRequest + */ +export type CreateCollectionRequest = Message<"collection.v1.CreateCollectionRequest"> & { + /** + * Type of collection to create (required) + * + * @generated from field: collection.v1.CollectionType type = 1; + */ + type: CollectionType; + + /** + * Label for the collection (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Optional description (max 500 characters) + * + * @generated from field: string description = 3; + */ + description: string; + + /** + * Type-specific metadata (rack_info required for racks, group_info optional for groups) + * + * @generated from oneof collection.v1.CreateCollectionRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: collection.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: collection.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: devices to add atomically when creating the collection. + * If provided, devices are added in the same transaction as collection creation. + * + * @generated from field: optional common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.CreateCollectionRequest. + * Use `create(CreateCollectionRequestSchema)` to create a new message. + */ +export const CreateCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 6); + +/** + * Response after creating a collection + * + * @generated from message collection.v1.CreateCollectionResponse + */ +export type CreateCollectionResponse = Message<"collection.v1.CreateCollectionResponse"> & { + /** + * The newly created collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; + + /** + * Number of devices added to the collection (0 if no device_selector was provided) + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message collection.v1.CreateCollectionResponse. + * Use `create(CreateCollectionResponseSchema)` to create a new message. + */ +export const CreateCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 7); + +/** + * Request to get a collection by ID + * + * @generated from message collection.v1.GetCollectionRequest + */ +export type GetCollectionRequest = Message<"collection.v1.GetCollectionRequest"> & { + /** + * ID of the collection to retrieve + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; +}; + +/** + * Describes the message collection.v1.GetCollectionRequest. + * Use `create(GetCollectionRequestSchema)` to create a new message. + */ +export const GetCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 8); + +/** + * Response containing the requested collection + * + * @generated from message collection.v1.GetCollectionResponse + */ +export type GetCollectionResponse = Message<"collection.v1.GetCollectionResponse"> & { + /** + * The requested collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; +}; + +/** + * Describes the message collection.v1.GetCollectionResponse. + * Use `create(GetCollectionResponseSchema)` to create a new message. + */ +export const GetCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 9); + +/** + * Request to update a collection + * + * @generated from message collection.v1.UpdateCollectionRequest + */ +export type UpdateCollectionRequest = Message<"collection.v1.UpdateCollectionRequest"> & { + /** + * ID of the collection to update + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * New label (optional, 1-100 characters if provided) + * + * @generated from field: optional string label = 2; + */ + label?: string; + + /** + * New description (optional, max 500 characters if provided). + * Omit the field to leave unchanged; set to empty string to clear. + * + * @generated from field: optional string description = 3; + */ + description?: string; + + /** + * Type-specific metadata updates (only applicable for the collection's type) + * + * @generated from oneof collection.v1.UpdateCollectionRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: collection.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: collection.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: atomically replace all collection members with the selected devices. + * + * @generated from field: common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.UpdateCollectionRequest. + * Use `create(UpdateCollectionRequestSchema)` to create a new message. + */ +export const UpdateCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 10); + +/** + * Response after updating a collection + * + * @generated from message collection.v1.UpdateCollectionResponse + */ +export type UpdateCollectionResponse = Message<"collection.v1.UpdateCollectionResponse"> & { + /** + * The updated collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; +}; + +/** + * Describes the message collection.v1.UpdateCollectionResponse. + * Use `create(UpdateCollectionResponseSchema)` to create a new message. + */ +export const UpdateCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 11); + +/** + * Request to delete a collection + * + * @generated from message collection.v1.DeleteCollectionRequest + */ +export type DeleteCollectionRequest = Message<"collection.v1.DeleteCollectionRequest"> & { + /** + * ID of the collection to delete + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; +}; + +/** + * Describes the message collection.v1.DeleteCollectionRequest. + * Use `create(DeleteCollectionRequestSchema)` to create a new message. + */ +export const DeleteCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 12); + +/** + * Response after deleting a collection + * + * Empty response - success indicated by gRPC status + * + * @generated from message collection.v1.DeleteCollectionResponse + */ +export type DeleteCollectionResponse = Message<"collection.v1.DeleteCollectionResponse"> & {}; + +/** + * Describes the message collection.v1.DeleteCollectionResponse. + * Use `create(DeleteCollectionResponseSchema)` to create a new message. + */ +export const DeleteCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 13); + +/** + * Request to list all collections + * + * @generated from message collection.v1.ListCollectionsRequest + */ +export type ListCollectionsRequest = Message<"collection.v1.ListCollectionsRequest"> & { + /** + * Filter by collection type (optional, returns all types if unspecified) + * + * @generated from field: collection.v1.CollectionType type = 1; + */ + type: CollectionType; + + /** + * Maximum number of collections to return (0 = server default) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; + + /** + * Sort configuration (defaults to name ascending). + * Supported fields: SORT_FIELD_NAME, SORT_FIELD_DEVICE_COUNT, SORT_FIELD_ISSUE_COUNT. + * + * @generated from field: common.v1.SortConfig sort = 4; + */ + sort?: SortConfig; + + /** + * Filter by collections containing devices with open errors of these component types. + * When non-empty, only collections with at least one device having an open error + * matching any of the specified component types are returned. + * + * @generated from field: repeated errors.v1.ComponentType error_component_types = 5; + */ + errorComponentTypes: ComponentType[]; + + /** + * Filter by rack zones. Only valid when type is RACK. + * When non-empty, only racks in any of the specified zones are returned. + * + * @generated from field: repeated string zones = 6; + */ + zones: string[]; +}; + +/** + * Describes the message collection.v1.ListCollectionsRequest. + * Use `create(ListCollectionsRequestSchema)` to create a new message. + */ +export const ListCollectionsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 14); + +/** + * Response containing collections + * + * @generated from message collection.v1.ListCollectionsResponse + */ +export type ListCollectionsResponse = Message<"collection.v1.ListCollectionsResponse"> & { + /** + * List of collections ordered by label + * + * @generated from field: repeated collection.v1.DeviceCollection collections = 1; + */ + collections: DeviceCollection[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Total number of collections matching the request filters + * + * @generated from field: int32 total_count = 3; + */ + totalCount: number; +}; + +/** + * Describes the message collection.v1.ListCollectionsResponse. + * Use `create(ListCollectionsResponseSchema)` to create a new message. + */ +export const ListCollectionsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 15); + +/** + * Request to add devices to a collection + * + * @generated from message collection.v1.AddDevicesToCollectionRequest + */ +export type AddDevicesToCollectionRequest = Message<"collection.v1.AddDevicesToCollectionRequest"> & { + /** + * ID of the collection to add devices to + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Devices to add: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.AddDevicesToCollectionRequest. + * Use `create(AddDevicesToCollectionRequestSchema)` to create a new message. + */ +export const AddDevicesToCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 16); + +/** + * Response after adding devices to a collection + * + * @generated from message collection.v1.AddDevicesToCollectionResponse + */ +export type AddDevicesToCollectionResponse = Message<"collection.v1.AddDevicesToCollectionResponse"> & { + /** + * ID of the collection devices were added to + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Number of devices successfully added + * May be less than requested if some devices were already members + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message collection.v1.AddDevicesToCollectionResponse. + * Use `create(AddDevicesToCollectionResponseSchema)` to create a new message. + */ +export const AddDevicesToCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 17); + +/** + * Request to remove devices from a collection + * + * @generated from message collection.v1.RemoveDevicesFromCollectionRequest + */ +export type RemoveDevicesFromCollectionRequest = Message<"collection.v1.RemoveDevicesFromCollectionRequest"> & { + /** + * ID of the collection to remove devices from + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Devices to remove: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message collection.v1.RemoveDevicesFromCollectionRequest. + * Use `create(RemoveDevicesFromCollectionRequestSchema)` to create a new message. + */ +export const RemoveDevicesFromCollectionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 18); + +/** + * Response after removing devices from a collection + * + * @generated from message collection.v1.RemoveDevicesFromCollectionResponse + */ +export type RemoveDevicesFromCollectionResponse = Message<"collection.v1.RemoveDevicesFromCollectionResponse"> & { + /** + * Number of devices successfully removed + * + * @generated from field: int32 removed_count = 1; + */ + removedCount: number; +}; + +/** + * Describes the message collection.v1.RemoveDevicesFromCollectionResponse. + * Use `create(RemoveDevicesFromCollectionResponseSchema)` to create a new message. + */ +export const RemoveDevicesFromCollectionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 19); + +/** + * Request to list members of a collection + * + * @generated from message collection.v1.ListCollectionMembersRequest + */ +export type ListCollectionMembersRequest = Message<"collection.v1.ListCollectionMembersRequest"> & { + /** + * ID of the collection to list members for + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Maximum number of members to return (default: all) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message collection.v1.ListCollectionMembersRequest. + * Use `create(ListCollectionMembersRequestSchema)` to create a new message. + */ +export const ListCollectionMembersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 20); + +/** + * Response containing collection members + * + * @generated from message collection.v1.ListCollectionMembersResponse + */ +export type ListCollectionMembersResponse = Message<"collection.v1.ListCollectionMembersResponse"> & { + /** + * List of members ordered by when they were added (newest first) + * + * @generated from field: repeated collection.v1.CollectionMember members = 1; + */ + members: CollectionMember[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message collection.v1.ListCollectionMembersResponse. + * Use `create(ListCollectionMembersResponseSchema)` to create a new message. + */ +export const ListCollectionMembersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 21); + +/** + * Request to get collections for a device + * + * @generated from message collection.v1.GetDeviceCollectionsRequest + */ +export type GetDeviceCollectionsRequest = Message<"collection.v1.GetDeviceCollectionsRequest"> & { + /** + * Device identifier to look up + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Filter by collection type (optional, returns all types if unspecified) + * + * @generated from field: collection.v1.CollectionType type = 2; + */ + type: CollectionType; +}; + +/** + * Describes the message collection.v1.GetDeviceCollectionsRequest. + * Use `create(GetDeviceCollectionsRequestSchema)` to create a new message. + */ +export const GetDeviceCollectionsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 22); + +/** + * Response containing collections the device belongs to + * + * @generated from message collection.v1.GetDeviceCollectionsResponse + */ +export type GetDeviceCollectionsResponse = Message<"collection.v1.GetDeviceCollectionsResponse"> & { + /** + * Collections the device belongs to, ordered by label + * + * @generated from field: repeated collection.v1.DeviceCollection collections = 1; + */ + collections: DeviceCollection[]; +}; + +/** + * Describes the message collection.v1.GetDeviceCollectionsResponse. + * Use `create(GetDeviceCollectionsResponseSchema)` to create a new message. + */ +export const GetDeviceCollectionsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 23); + +/** + * Request to set a device's slot position within a rack + * + * @generated from message collection.v1.SetRackSlotPositionRequest + */ +export type SetRackSlotPositionRequest = Message<"collection.v1.SetRackSlotPositionRequest"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Device to position + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; + + /** + * Target slot position + * + * @generated from field: collection.v1.RackSlotPosition position = 3; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message collection.v1.SetRackSlotPositionRequest. + * Use `create(SetRackSlotPositionRequestSchema)` to create a new message. + */ +export const SetRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 24); + +/** + * Response after setting a rack slot position + * + * @generated from message collection.v1.SetRackSlotPositionResponse + */ +export type SetRackSlotPositionResponse = Message<"collection.v1.SetRackSlotPositionResponse"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * The slot that was set + * + * @generated from field: collection.v1.RackSlot slot = 2; + */ + slot?: RackSlot; +}; + +/** + * Describes the message collection.v1.SetRackSlotPositionResponse. + * Use `create(SetRackSlotPositionResponseSchema)` to create a new message. + */ +export const SetRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 25); + +/** + * Request to clear a device's slot position within a rack + * + * @generated from message collection.v1.ClearRackSlotPositionRequest + */ +export type ClearRackSlotPositionRequest = Message<"collection.v1.ClearRackSlotPositionRequest"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Device to unposition + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message collection.v1.ClearRackSlotPositionRequest. + * Use `create(ClearRackSlotPositionRequestSchema)` to create a new message. + */ +export const ClearRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 26); + +/** + * Response after clearing a rack slot position + * + * @generated from message collection.v1.ClearRackSlotPositionResponse + */ +export type ClearRackSlotPositionResponse = Message<"collection.v1.ClearRackSlotPositionResponse"> & {}; + +/** + * Describes the message collection.v1.ClearRackSlotPositionResponse. + * Use `create(ClearRackSlotPositionResponseSchema)` to create a new message. + */ +export const ClearRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 27); + +/** + * Request to list all occupied slots in a rack + * + * @generated from message collection.v1.GetRackSlotsRequest + */ +export type GetRackSlotsRequest = Message<"collection.v1.GetRackSlotsRequest"> & { + /** + * ID of the rack collection + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; +}; + +/** + * Describes the message collection.v1.GetRackSlotsRequest. + * Use `create(GetRackSlotsRequestSchema)` to create a new message. + */ +export const GetRackSlotsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 28); + +/** + * Represents a device assigned to a specific slot in a rack + * + * @generated from message collection.v1.RackSlot + */ +export type RackSlot = Message<"collection.v1.RackSlot"> & { + /** + * Device in this slot + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Slot position within the rack + * + * @generated from field: collection.v1.RackSlotPosition position = 2; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message collection.v1.RackSlot. + * Use `create(RackSlotSchema)` to create a new message. + */ +export const RackSlotSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 29); + +/** + * Response containing all occupied rack slots + * + * @generated from message collection.v1.GetRackSlotsResponse + */ +export type GetRackSlotsResponse = Message<"collection.v1.GetRackSlotsResponse"> & { + /** + * Occupied slots ordered by row then column + * + * @generated from field: repeated collection.v1.RackSlot slots = 1; + */ + slots: RackSlot[]; +}; + +/** + * Describes the message collection.v1.GetRackSlotsResponse. + * Use `create(GetRackSlotsResponseSchema)` to create a new message. + */ +export const GetRackSlotsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 30); + +/** + * Aggregated telemetry stats for a single collection + * + * @generated from message collection.v1.CollectionStats + */ +export type CollectionStats = Message<"collection.v1.CollectionStats"> & { + /** + * Collection identifier + * + * @generated from field: int64 collection_id = 1; + */ + collectionId: bigint; + + /** + * Total number of devices in the collection + * + * @generated from field: int32 device_count = 2; + */ + deviceCount: number; + + /** + * Number of devices with recent telemetry data + * + * @generated from field: int32 reporting_count = 3; + */ + reportingCount: number; + + /** + * Aggregated telemetry (totals/averages across reporting devices) + * + * @generated from field: double total_hashrate_ths = 4; + */ + totalHashrateThs: number; + + /** + * @generated from field: double avg_efficiency_jth = 5; + */ + avgEfficiencyJth: number; + + /** + * @generated from field: double total_power_kw = 6; + */ + totalPowerKw: number; + + /** + * @generated from field: double min_temperature_c = 7; + */ + minTemperatureC: number; + + /** + * @generated from field: double max_temperature_c = 8; + */ + maxTemperatureC: number; + + /** + * Fleet health state counts (mirrors dashboard FleetHealth buckets) + * + * ACTIVE + no auth issues + no actionable errors + * + * @generated from field: int32 hashing_count = 9; + */ + hashingCount: number; + + /** + * ERROR/NEEDS_MINING_POOL/AUTH_NEEDED or has open errors + * + * @generated from field: int32 broken_count = 10; + */ + brokenCount: number; + + /** + * OFFLINE or NULL status + * + * @generated from field: int32 offline_count = 11; + */ + offlineCount: number; + + /** + * MAINTENANCE or INACTIVE + * + * @generated from field: int32 sleeping_count = 12; + */ + sleepingCount: number; + + /** + * Per-metric reporting counts (devices that report each specific metric) + * + * @generated from field: int32 hashrate_reporting_count = 13; + */ + hashrateReportingCount: number; + + /** + * @generated from field: int32 efficiency_reporting_count = 14; + */ + efficiencyReportingCount: number; + + /** + * @generated from field: int32 power_reporting_count = 15; + */ + powerReportingCount: number; + + /** + * @generated from field: int32 temperature_reporting_count = 16; + */ + temperatureReportingCount: number; + + /** + * Component issue counts (number of devices with open errors by component type) + * + * @generated from field: int32 control_board_issue_count = 17; + */ + controlBoardIssueCount: number; + + /** + * @generated from field: int32 fan_issue_count = 18; + */ + fanIssueCount: number; + + /** + * @generated from field: int32 hash_board_issue_count = 19; + */ + hashBoardIssueCount: number; + + /** + * @generated from field: int32 psu_issue_count = 20; + */ + psuIssueCount: number; + + /** + * Per-slot device status for rack-type collections (empty for groups). + * Contains one entry per row×column position, including empty slots. + * + * @generated from field: repeated collection.v1.RackSlotStatus slot_statuses = 21; + */ + slotStatuses: RackSlotStatus[]; +}; + +/** + * Describes the message collection.v1.CollectionStats. + * Use `create(CollectionStatsSchema)` to create a new message. + */ +export const CollectionStatsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 31); + +/** + * Request to get aggregated stats for collections + * + * @generated from message collection.v1.GetCollectionStatsRequest + */ +export type GetCollectionStatsRequest = Message<"collection.v1.GetCollectionStatsRequest"> & { + /** + * Collection IDs to get stats for + * + * @generated from field: repeated int64 collection_ids = 1; + */ + collectionIds: bigint[]; +}; + +/** + * Describes the message collection.v1.GetCollectionStatsRequest. + * Use `create(GetCollectionStatsRequestSchema)` to create a new message. + */ +export const GetCollectionStatsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 32); + +/** + * Response containing stats for each requested collection + * + * @generated from message collection.v1.GetCollectionStatsResponse + */ +export type GetCollectionStatsResponse = Message<"collection.v1.GetCollectionStatsResponse"> & { + /** + * Stats per collection (one entry per requested collection ID) + * + * @generated from field: repeated collection.v1.CollectionStats stats = 1; + */ + stats: CollectionStats[]; +}; + +/** + * Describes the message collection.v1.GetCollectionStatsResponse. + * Use `create(GetCollectionStatsResponseSchema)` to create a new message. + */ +export const GetCollectionStatsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 33); + +/** + * Status of a single slot in a rack grid + * + * @generated from message collection.v1.RackSlotStatus + */ +export type RackSlotStatus = Message<"collection.v1.RackSlotStatus"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; + + /** + * Device status for this slot + * + * @generated from field: collection.v1.SlotDeviceStatus status = 3; + */ + status: SlotDeviceStatus; +}; + +/** + * Describes the message collection.v1.RackSlotStatus. + * Use `create(RackSlotStatusSchema)` to create a new message. + */ +export const RackSlotStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 34); + +/** + * Request to list all distinct rack zones for the organization + * + * @generated from message collection.v1.ListRackZonesRequest + */ +export type ListRackZonesRequest = Message<"collection.v1.ListRackZonesRequest"> & {}; + +/** + * Describes the message collection.v1.ListRackZonesRequest. + * Use `create(ListRackZonesRequestSchema)` to create a new message. + */ +export const ListRackZonesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 35); + +/** + * Response containing all distinct rack zones + * + * @generated from message collection.v1.ListRackZonesResponse + */ +export type ListRackZonesResponse = Message<"collection.v1.ListRackZonesResponse"> & { + /** + * Distinct zone strings across all racks, sorted alphabetically + * + * @generated from field: repeated string zones = 1; + */ + zones: string[]; +}; + +/** + * Describes the message collection.v1.ListRackZonesResponse. + * Use `create(ListRackZonesResponseSchema)` to create a new message. + */ +export const ListRackZonesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 36); + +/** + * Request to list all distinct rack types for the organization + * + * @generated from message collection.v1.ListRackTypesRequest + */ +export type ListRackTypesRequest = Message<"collection.v1.ListRackTypesRequest"> & {}; + +/** + * Describes the message collection.v1.ListRackTypesRequest. + * Use `create(ListRackTypesRequestSchema)` to create a new message. + */ +export const ListRackTypesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 37); + +/** + * A rack type defined by its row/column dimensions and how many racks use it + * + * @generated from message collection.v1.RackType + */ +export type RackType = Message<"collection.v1.RackType"> & { + /** + * Number of rows + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Number of racks using this layout + * + * @generated from field: int32 rack_count = 3; + */ + rackCount: number; +}; + +/** + * Describes the message collection.v1.RackType. + * Use `create(RackTypeSchema)` to create a new message. + */ +export const RackTypeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_collection_v1_collection, 38); + +/** + * Response containing all distinct rack types + * + * @generated from message collection.v1.ListRackTypesResponse + */ +export type ListRackTypesResponse = Message<"collection.v1.ListRackTypesResponse"> & { + /** + * Distinct rack types ordered by most recently created rack using that layout + * + * @generated from field: repeated collection.v1.RackType rack_types = 1; + */ + rackTypes: RackType[]; +}; + +/** + * Describes the message collection.v1.ListRackTypesResponse. + * Use `create(ListRackTypesResponseSchema)` to create a new message. + */ +export const ListRackTypesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 39); + +/** + * Request to atomically create or update a rack with membership and slot assignments. + * + * @generated from message collection.v1.SaveRackRequest + */ +export type SaveRackRequest = Message<"collection.v1.SaveRackRequest"> & { + /** + * ID of an existing rack to update. Omit to create a new rack. + * + * @generated from field: optional int64 collection_id = 1; + */ + collectionId?: bigint; + + /** + * Label for the rack (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Rack-specific metadata (required) + * + * @generated from field: collection.v1.RackInfo rack_info = 3; + */ + rackInfo?: RackInfo; + + /** + * Devices that should be members of this rack. + * Replaces all existing members atomically. + * + * @generated from field: common.v1.DeviceSelector device_selector = 4; + */ + deviceSelector?: DeviceSelector; + + /** + * Slot assignments for devices within the rack. + * Only devices included in device_selector may be assigned slots. + * Devices not listed here will be members without a slot position. + * + * @generated from field: repeated collection.v1.RackSlot slot_assignments = 5; + */ + slotAssignments: RackSlot[]; +}; + +/** + * Describes the message collection.v1.SaveRackRequest. + * Use `create(SaveRackRequestSchema)` to create a new message. + */ +export const SaveRackRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 40); + +/** + * Response after saving a rack. + * + * @generated from message collection.v1.SaveRackResponse + */ +export type SaveRackResponse = Message<"collection.v1.SaveRackResponse"> & { + /** + * The created or updated rack collection + * + * @generated from field: collection.v1.DeviceCollection collection = 1; + */ + collection?: DeviceCollection; + + /** + * Number of slot positions assigned + * + * @generated from field: int32 assigned_count = 2; + */ + assignedCount: number; +}; + +/** + * Describes the message collection.v1.SaveRackResponse. + * Use `create(SaveRackResponseSchema)` to create a new message. + */ +export const SaveRackResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_collection_v1_collection, 41); + +/** + * Type of collection + * + * @generated from enum collection.v1.CollectionType + */ +export enum CollectionType { + /** + * Unspecified type - returns all types when filtering + * + * @generated from enum value: COLLECTION_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Group: many-to-many relationship (device can belong to multiple groups) + * + * @generated from enum value: COLLECTION_TYPE_GROUP = 1; + */ + GROUP = 1, + + /** + * Rack: one-to-one relationship (device can only be in one rack) + * + * @generated from enum value: COLLECTION_TYPE_RACK = 2; + */ + RACK = 2, +} + +/** + * Describes the enum collection.v1.CollectionType. + */ +export const CollectionTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_collection_v1_collection, 0); + +/** + * Order index defining where row/column numbering starts in a rack + * + * @generated from enum collection.v1.RackOrderIndex + */ +export enum RackOrderIndex { + /** + * @generated from enum value: RACK_ORDER_INDEX_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_LEFT = 1; + */ + BOTTOM_LEFT = 1, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_LEFT = 2; + */ + TOP_LEFT = 2, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_RIGHT = 3; + */ + BOTTOM_RIGHT = 3, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_RIGHT = 4; + */ + TOP_RIGHT = 4, +} + +/** + * Describes the enum collection.v1.RackOrderIndex. + */ +export const RackOrderIndexSchema: GenEnum = /*@__PURE__*/ enumDesc(file_collection_v1_collection, 1); + +/** + * Cooling type for a rack + * + * @generated from enum collection.v1.RackCoolingType + */ +export enum RackCoolingType { + /** + * @generated from enum value: RACK_COOLING_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_COOLING_TYPE_AIR = 1; + */ + AIR = 1, + + /** + * @generated from enum value: RACK_COOLING_TYPE_IMMERSION = 2; + */ + IMMERSION = 2, +} + +/** + * Describes the enum collection.v1.RackCoolingType. + */ +export const RackCoolingTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_collection_v1_collection, 2); + +/** + * Status of a device in a specific rack slot position + * + * @generated from enum collection.v1.SlotDeviceStatus + */ +export enum SlotDeviceStatus { + /** + * @generated from enum value: SLOT_DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_EMPTY = 1; + */ + EMPTY = 1, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_HEALTHY = 2; + */ + HEALTHY = 2, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_NEEDS_ATTENTION = 3; + */ + NEEDS_ATTENTION = 3, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_OFFLINE = 4; + */ + OFFLINE = 4, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_SLEEPING = 5; + */ + SLEEPING = 5, +} + +/** + * Describes the enum collection.v1.SlotDeviceStatus. + */ +export const SlotDeviceStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_collection_v1_collection, 3); + +/** + * Deprecated: Use device_set.v1.DeviceSetService instead. + * Service for managing device collections (groups and racks) + * Collections allow grouping devices for filtering and bulk operations + * + * @generated from service collection.v1.DeviceCollectionService + */ +export const DeviceCollectionService: GenService<{ + /** + * Creates a new collection + * + * @generated from rpc collection.v1.DeviceCollectionService.CreateCollection + */ + createCollection: { + methodKind: "unary"; + input: typeof CreateCollectionRequestSchema; + output: typeof CreateCollectionResponseSchema; + }; + /** + * Gets a collection by ID + * + * @generated from rpc collection.v1.DeviceCollectionService.GetCollection + */ + getCollection: { + methodKind: "unary"; + input: typeof GetCollectionRequestSchema; + output: typeof GetCollectionResponseSchema; + }; + /** + * Updates a collection's label or description + * + * @generated from rpc collection.v1.DeviceCollectionService.UpdateCollection + */ + updateCollection: { + methodKind: "unary"; + input: typeof UpdateCollectionRequestSchema; + output: typeof UpdateCollectionResponseSchema; + }; + /** + * Deletes a collection (soft delete) + * + * @generated from rpc collection.v1.DeviceCollectionService.DeleteCollection + */ + deleteCollection: { + methodKind: "unary"; + input: typeof DeleteCollectionRequestSchema; + output: typeof DeleteCollectionResponseSchema; + }; + /** + * Lists all collections for the organization + * + * @generated from rpc collection.v1.DeviceCollectionService.ListCollections + */ + listCollections: { + methodKind: "unary"; + input: typeof ListCollectionsRequestSchema; + output: typeof ListCollectionsResponseSchema; + }; + /** + * Adds devices to a collection + * + * @generated from rpc collection.v1.DeviceCollectionService.AddDevicesToCollection + */ + addDevicesToCollection: { + methodKind: "unary"; + input: typeof AddDevicesToCollectionRequestSchema; + output: typeof AddDevicesToCollectionResponseSchema; + }; + /** + * Removes devices from a collection + * + * @generated from rpc collection.v1.DeviceCollectionService.RemoveDevicesFromCollection + */ + removeDevicesFromCollection: { + methodKind: "unary"; + input: typeof RemoveDevicesFromCollectionRequestSchema; + output: typeof RemoveDevicesFromCollectionResponseSchema; + }; + /** + * Lists members of a collection + * + * @generated from rpc collection.v1.DeviceCollectionService.ListCollectionMembers + */ + listCollectionMembers: { + methodKind: "unary"; + input: typeof ListCollectionMembersRequestSchema; + output: typeof ListCollectionMembersResponseSchema; + }; + /** + * Gets collections that a device belongs to + * + * @generated from rpc collection.v1.DeviceCollectionService.GetDeviceCollections + */ + getDeviceCollections: { + methodKind: "unary"; + input: typeof GetDeviceCollectionsRequestSchema; + output: typeof GetDeviceCollectionsResponseSchema; + }; + /** + * Sets a device's slot position within a rack + * + * @generated from rpc collection.v1.DeviceCollectionService.SetRackSlotPosition + */ + setRackSlotPosition: { + methodKind: "unary"; + input: typeof SetRackSlotPositionRequestSchema; + output: typeof SetRackSlotPositionResponseSchema; + }; + /** + * Clears a device's slot position within a rack + * + * @generated from rpc collection.v1.DeviceCollectionService.ClearRackSlotPosition + */ + clearRackSlotPosition: { + methodKind: "unary"; + input: typeof ClearRackSlotPositionRequestSchema; + output: typeof ClearRackSlotPositionResponseSchema; + }; + /** + * Lists all occupied slot positions in a rack + * + * @generated from rpc collection.v1.DeviceCollectionService.GetRackSlots + */ + getRackSlots: { + methodKind: "unary"; + input: typeof GetRackSlotsRequestSchema; + output: typeof GetRackSlotsResponseSchema; + }; + /** + * Returns aggregated telemetry stats for a list of collections + * + * @generated from rpc collection.v1.DeviceCollectionService.GetCollectionStats + */ + getCollectionStats: { + methodKind: "unary"; + input: typeof GetCollectionStatsRequestSchema; + output: typeof GetCollectionStatsResponseSchema; + }; + /** + * Returns all distinct rack zones for the organization + * + * @generated from rpc collection.v1.DeviceCollectionService.ListRackZones + */ + listRackZones: { + methodKind: "unary"; + input: typeof ListRackZonesRequestSchema; + output: typeof ListRackZonesResponseSchema; + }; + /** + * Returns all distinct rack types (row/column combinations) for the organization + * + * @generated from rpc collection.v1.DeviceCollectionService.ListRackTypes + */ + listRackTypes: { + methodKind: "unary"; + input: typeof ListRackTypesRequestSchema; + output: typeof ListRackTypesResponseSchema; + }; + /** + * Atomically creates or updates a rack with its membership and slot assignments. + * All operations (metadata, membership, slot positions) are applied in a single transaction. + * + * @generated from rpc collection.v1.DeviceCollectionService.SaveRack + */ + saveRack: { + methodKind: "unary"; + input: typeof SaveRackRequestSchema; + output: typeof SaveRackResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_collection_v1_collection, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/common_pb.ts b/client/src/protoFleet/api/generated/common/v1/common_pb.ts new file mode 100644 index 000000000..b05233644 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/common_pb.ts @@ -0,0 +1,71 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/common.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/common.proto. + */ +export const file_common_v1_common: GenFile = + /*@__PURE__*/ + fileDesc( + "ChZjb21tb24vdjEvY29tbW9uLnByb3RvEgljb21tb24udjEibwoRRmxlZXRFcnJvckRldGFpbHMSKwoGY29tbW9uGAEgASgOMhkuY29tbW9uLnYxLkZsZWV0RXJyb3JDb2RlSAASEQoHc2VydmljZRgCIAEoBUgAEhIKCGVuZHBvaW50GAMgASgFSABCBgoEY29kZSoyCg5GbGVldEVycm9yQ29kZRIgChxGTEVFVF9FUlJPUl9DT0RFX1VOU1BFQ0lGSUVEEABCqAEKDWNvbS5jb21tb24udjFCC0NvbW1vblByb3RvUAFaRWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2NvbW1vbi92MTtjb21tb252MaICA0NYWKoCCUNvbW1vbi5WMcoCCUNvbW1vblxWMeICFUNvbW1vblxWMVxHUEJNZXRhZGF0YeoCCkNvbW1vbjo6VjFiBnByb3RvMw", + ); + +/** + * @generated from message common.v1.FleetErrorDetails + */ +export type FleetErrorDetails = Message<"common.v1.FleetErrorDetails"> & { + /** + * @generated from oneof common.v1.FleetErrorDetails.code + */ + code: + | { + /** + * @generated from field: common.v1.FleetErrorCode common = 1; + */ + value: FleetErrorCode; + case: "common"; + } + | { + /** + * @generated from field: int32 service = 2; + */ + value: number; + case: "service"; + } + | { + /** + * @generated from field: int32 endpoint = 3; + */ + value: number; + case: "endpoint"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message common.v1.FleetErrorDetails. + * Use `create(FleetErrorDetailsSchema)` to create a new message. + */ +export const FleetErrorDetailsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_common_v1_common, 0); + +/** + * @generated from enum common.v1.FleetErrorCode + */ +export enum FleetErrorCode { + /** + * @generated from enum value: FLEET_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, +} + +/** + * Describes the enum common.v1.FleetErrorCode. + */ +export const FleetErrorCodeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_common, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/cooling_pb.ts b/client/src/protoFleet/api/generated/common/v1/cooling_pb.ts new file mode 100644 index 000000000..107ef8ac0 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/cooling_pb.ts @@ -0,0 +1,47 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/cooling.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file common/v1/cooling.proto. + */ +export const file_common_v1_cooling: GenFile = + /*@__PURE__*/ + fileDesc( + "Chdjb21tb24vdjEvY29vbGluZy5wcm90bxIJY29tbW9uLnYxKoQBCgtDb29saW5nTW9kZRIcChhDT09MSU5HX01PREVfVU5TUEVDSUZJRUQQABIbChdDT09MSU5HX01PREVfQUlSX0NPT0xFRBABEiEKHUNPT0xJTkdfTU9ERV9JTU1FUlNJT05fQ09PTEVEEAISFwoTQ09PTElOR19NT0RFX01BTlVBTBADQqkBCg1jb20uY29tbW9uLnYxQgxDb29saW5nUHJvdG9QAVpFZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvY29tbW9uL3YxO2NvbW1vbnYxogIDQ1hYqgIJQ29tbW9uLlYxygIJQ29tbW9uXFYx4gIVQ29tbW9uXFYxXEdQQk1ldGFkYXRh6gIKQ29tbW9uOjpWMWIGcHJvdG8z", + ); + +/** + * CoolingMode represents the cooling configuration for a miner device. + * + * @generated from enum common.v1.CoolingMode + */ +export enum CoolingMode { + /** + * @generated from enum value: COOLING_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COOLING_MODE_AIR_COOLED = 1; + */ + AIR_COOLED = 1, + + /** + * @generated from enum value: COOLING_MODE_IMMERSION_COOLED = 2; + */ + IMMERSION_COOLED = 2, + + /** + * @generated from enum value: COOLING_MODE_MANUAL = 3; + */ + MANUAL = 3, +} + +/** + * Describes the enum common.v1.CoolingMode. + */ +export const CoolingModeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_cooling, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/device_selector_pb.ts b/client/src/protoFleet/api/generated/common/v1/device_selector_pb.ts new file mode 100644 index 000000000..862ddf26b --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/device_selector_pb.ts @@ -0,0 +1,77 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/device_selector.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/device_selector.proto. + */ +export const file_common_v1_device_selector: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch9jb21tb24vdjEvZGV2aWNlX3NlbGVjdG9yLnByb3RvEgljb21tb24udjEicQoORGV2aWNlU2VsZWN0b3ISNgoLZGV2aWNlX2xpc3QYASABKAsyHy5jb21tb24udjEuRGV2aWNlSWRlbnRpZmllckxpc3RIABIVCgthbGxfZGV2aWNlcxgCIAEoCEgAQhAKDnNlbGVjdGlvbl90eXBlIjIKFERldmljZUlkZW50aWZpZXJMaXN0EhoKEmRldmljZV9pZGVudGlmaWVycxgBIAMoCUKwAQoNY29tLmNvbW1vbi52MUITRGV2aWNlU2VsZWN0b3JQcm90b1ABWkVnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9jb21tb24vdjE7Y29tbW9udjGiAgNDWFiqAglDb21tb24uVjHKAglDb21tb25cVjHiAhVDb21tb25cVjFcR1BCTWV0YWRhdGHqAgpDb21tb246OlYxYgZwcm90bzM", + ); + +/** + * Selects devices for cross-service operations. + * Services needing filter-based selection (e.g., fleetmanagement, minercommand) + * may define their own extended DeviceSelector with service-specific filter types. + * + * @generated from message common.v1.DeviceSelector + */ +export type DeviceSelector = Message<"common.v1.DeviceSelector"> & { + /** + * @generated from oneof common.v1.DeviceSelector.selection_type + */ + selectionType: + | { + /** + * Select specific devices by their identifiers + * + * @generated from field: common.v1.DeviceIdentifierList device_list = 1; + */ + value: DeviceIdentifierList; + case: "deviceList"; + } + | { + /** + * Select all paired devices in the organization + * + * @generated from field: bool all_devices = 2; + */ + value: boolean; + case: "allDevices"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message common.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_common_v1_device_selector, 0); + +/** + * List of device identifiers for explicit device selection + * + * @generated from message common.v1.DeviceIdentifierList + */ +export type DeviceIdentifierList = Message<"common.v1.DeviceIdentifierList"> & { + /** + * @generated from field: repeated string device_identifiers = 1; + */ + deviceIdentifiers: string[]; +}; + +/** + * Describes the message common.v1.DeviceIdentifierList. + * Use `create(DeviceIdentifierListSchema)` to create a new message. + */ +export const DeviceIdentifierListSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_common_v1_device_selector, 1); diff --git a/client/src/protoFleet/api/generated/common/v1/measurement_pb.ts b/client/src/protoFleet/api/generated/common/v1/measurement_pb.ts new file mode 100644 index 000000000..a3e678778 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/measurement_pb.ts @@ -0,0 +1,121 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/measurement.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/measurement.proto. + */ +export const file_common_v1_measurement: GenFile = + /*@__PURE__*/ + fileDesc( + "Chtjb21tb24vdjEvbWVhc3VyZW1lbnQucHJvdG8SCWNvbW1vbi52MSJ1CgtNZWFzdXJlbWVudBItCgl0aW1lc3RhbXAYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg0KBXZhbHVlGAIgASgBEigKBHVuaXQYAyABKA4yGi5jb21tb24udjEuTWVhc3VyZW1lbnRVbml0KqICCg9NZWFzdXJlbWVudFVuaXQSIAocTUVBU1VSRU1FTlRfVU5JVF9VTlNQRUNJRklFRBAAEigKJE1FQVNVUkVNRU5UX1VOSVRfVEVSQUhBU0hfUEVSX1NFQ09ORBABEigKJE1FQVNVUkVNRU5UX1VOSVRfSk9VTEVTX1BFUl9URVJBSEFTSBACEh0KGU1FQVNVUkVNRU5UX1VOSVRfS0lMT1dBVFQQAxIcChhNRUFTVVJFTUVOVF9VTklUX0NFTFNJVVMQBBIfChtNRUFTVVJFTUVOVF9VTklUX0ZBSFJFTkhFSVQQBRIfChtNRUFTVVJFTUVOVF9VTklUX1BFUkNFTlRBR0UQBhIaChZNRUFTVVJFTUVOVF9VTklUX0hPVVJTEAdCrQEKDWNvbS5jb21tb24udjFCEE1lYXN1cmVtZW50UHJvdG9QAVpFZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvY29tbW9uL3YxO2NvbW1vbnYxogIDQ1hYqgIJQ29tbW9uLlYxygIJQ29tbW9uXFYx4gIVQ29tbW9uXFYxXEdQQk1ldGFkYXRh6gIKQ29tbW9uOjpWMWIGcHJvdG8z", + [file_google_protobuf_timestamp], + ); + +/** + * A single measurement with timestamp, value, and unit + * + * @generated from message common.v1.Measurement + */ +export type Measurement = Message<"common.v1.Measurement"> & { + /** + * Timestamp of when the measurement was taken + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Numeric value of the measurement + * + * @generated from field: double value = 2; + */ + value: number; + + /** + * Unit of measurement + * + * @generated from field: common.v1.MeasurementUnit unit = 3; + */ + unit: MeasurementUnit; +}; + +/** + * Describes the message common.v1.Measurement. + * Use `create(MeasurementSchema)` to create a new message. + */ +export const MeasurementSchema: GenMessage = /*@__PURE__*/ messageDesc(file_common_v1_measurement, 0); + +/** + * Standard units used throughout the API + * + * @generated from enum common.v1.MeasurementUnit + */ +export enum MeasurementUnit { + /** + * Unit not specified + * + * @generated from enum value: MEASUREMENT_UNIT_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Terahash per second - for hashrate measurements + * + * @generated from enum value: MEASUREMENT_UNIT_TERAHASH_PER_SECOND = 1; + */ + TERAHASH_PER_SECOND = 1, + + /** + * Joules per terahash - for efficiency measurements + * + * @generated from enum value: MEASUREMENT_UNIT_JOULES_PER_TERAHASH = 2; + */ + JOULES_PER_TERAHASH = 2, + + /** + * Kilowatt - for power consumption measurements + * + * @generated from enum value: MEASUREMENT_UNIT_KILOWATT = 3; + */ + KILOWATT = 3, + + /** + * Degrees Celsius - for temperature measurements + * + * @generated from enum value: MEASUREMENT_UNIT_CELSIUS = 4; + */ + CELSIUS = 4, + + /** + * Degrees Fahrenheit - for temperature measurements + * + * @generated from enum value: MEASUREMENT_UNIT_FAHRENHEIT = 5; + */ + FAHRENHEIT = 5, + + /** + * Percentage - for uptime and efficiency percentages + * + * @generated from enum value: MEASUREMENT_UNIT_PERCENTAGE = 6; + */ + PERCENTAGE = 6, + + /** + * Hours - for uptime measurements + * + * @generated from enum value: MEASUREMENT_UNIT_HOURS = 7; + */ + HOURS = 7, +} + +/** + * Describes the enum common.v1.MeasurementUnit. + */ +export const MeasurementUnitSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_measurement, 0); diff --git a/client/src/protoFleet/api/generated/common/v1/sort_pb.ts b/client/src/protoFleet/api/generated/common/v1/sort_pb.ts new file mode 100644 index 000000000..d64703d08 --- /dev/null +++ b/client/src/protoFleet/api/generated/common/v1/sort_pb.ts @@ -0,0 +1,189 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file common/v1/sort.proto (package common.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file common/v1/sort.proto. + */ +export const file_common_v1_sort: GenFile = + /*@__PURE__*/ + fileDesc( + "ChRjb21tb24vdjEvc29ydC5wcm90bxIJY29tbW9uLnYxInIKClNvcnRDb25maWcSLQoFZmllbGQYASABKA4yFC5jb21tb24udjEuU29ydEZpZWxkQgi6SAWCAQIQARI1CglkaXJlY3Rpb24YAiABKA4yGC5jb21tb24udjEuU29ydERpcmVjdGlvbkIIukgFggECEAEqqAMKCVNvcnRGaWVsZBIaChZTT1JUX0ZJRUxEX1VOU1BFQ0lGSUVEEAASEwoPU09SVF9GSUVMRF9OQU1FEAESGQoVU09SVF9GSUVMRF9JUF9BRERSRVNTEAISGgoWU09SVF9GSUVMRF9NQUNfQUREUkVTUxADEhQKEFNPUlRfRklFTERfTU9ERUwQBRIXChNTT1JUX0ZJRUxEX0hBU0hSQVRFEAYSGgoWU09SVF9GSUVMRF9URU1QRVJBVFVSRRAHEhQKEFNPUlRfRklFTERfUE9XRVIQCBIZChVTT1JUX0ZJRUxEX0VGRklDSUVOQ1kQCRIaChZTT1JUX0ZJRUxEX0lTU1VFX0NPVU5UEA8SFwoTU09SVF9GSUVMRF9GSVJNV0FSRRALEhsKF1NPUlRfRklFTERfREVWSUNFX0NPVU5UEAwSFwoTU09SVF9GSUVMRF9MT0NBVElPThANEhoKFlNPUlRfRklFTERfV09SS0VSX05BTUUQDiIECAQQBCIECAoQCioRU09SVF9GSUVMRF9TVEFUVVMqEVNPUlRfRklFTERfSVNTVUVTKmAKDVNvcnREaXJlY3Rpb24SHgoaU09SVF9ESVJFQ1RJT05fVU5TUEVDSUZJRUQQABIWChJTT1JUX0RJUkVDVElPTl9BU0MQARIXChNTT1JUX0RJUkVDVElPTl9ERVNDEAJCpgEKDWNvbS5jb21tb24udjFCCVNvcnRQcm90b1ABWkVnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9jb21tb24vdjE7Y29tbW9udjGiAgNDWFiqAglDb21tb24uVjHKAglDb21tb25cVjHiAhVDb21tb25cVjFcR1BCTWV0YWRhdGHqAgpDb21tb246OlYxYgZwcm90bzM", + [file_buf_validate_validate], + ); + +/** + * Configuration for sorting list results + * + * @generated from message common.v1.SortConfig + */ +export type SortConfig = Message<"common.v1.SortConfig"> & { + /** + * Field to sort by + * + * @generated from field: common.v1.SortField field = 1; + */ + field: SortField; + + /** + * Direction to sort + * + * @generated from field: common.v1.SortDirection direction = 2; + */ + direction: SortDirection; +}; + +/** + * Describes the message common.v1.SortConfig. + * Use `create(SortConfigSchema)` to create a new message. + */ +export const SortConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_common_v1_sort, 0); + +/** + * Field to sort by in list operations. + * Not all fields are supported by every endpoint — see individual RPCs for valid options. + * + * @generated from enum common.v1.SortField + */ +export enum SortField { + /** + * Unspecified sort field - uses default sort (name ASC) + * + * @generated from enum value: SORT_FIELD_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Sort by name/label + * + * @generated from enum value: SORT_FIELD_NAME = 1; + */ + NAME = 1, + + /** + * Sort by IP address (numeric comparison) + * + * @generated from enum value: SORT_FIELD_IP_ADDRESS = 2; + */ + IP_ADDRESS = 2, + + /** + * Sort by MAC address + * + * @generated from enum value: SORT_FIELD_MAC_ADDRESS = 3; + */ + MAC_ADDRESS = 3, + + /** + * Sort by device model (e.g., "S21 XP", "M60") + * + * @generated from enum value: SORT_FIELD_MODEL = 5; + */ + MODEL = 5, + + /** + * Sort by hashrate (TH/s) + * + * @generated from enum value: SORT_FIELD_HASHRATE = 6; + */ + HASHRATE = 6, + + /** + * Sort by temperature (Celsius) + * + * @generated from enum value: SORT_FIELD_TEMPERATURE = 7; + */ + TEMPERATURE = 7, + + /** + * Sort by power consumption (kW) + * + * @generated from enum value: SORT_FIELD_POWER = 8; + */ + POWER = 8, + + /** + * Sort by efficiency (J/TH) + * + * @generated from enum value: SORT_FIELD_EFFICIENCY = 9; + */ + EFFICIENCY = 9, + + /** + * Sort by issue count (for collection listing) + * + * @generated from enum value: SORT_FIELD_ISSUE_COUNT = 15; + */ + ISSUE_COUNT = 15, + + /** + * Sort by firmware version + * + * @generated from enum value: SORT_FIELD_FIRMWARE = 11; + */ + FIRMWARE = 11, + + /** + * Sort by device count (for collection listing) + * + * @generated from enum value: SORT_FIELD_DEVICE_COUNT = 12; + */ + DEVICE_COUNT = 12, + + /** + * Sort by location (for rack listing) + * + * @generated from enum value: SORT_FIELD_LOCATION = 13; + */ + LOCATION = 13, + + /** + * Sort by the worker name stored on fleet + * + * @generated from enum value: SORT_FIELD_WORKER_NAME = 14; + */ + WORKER_NAME = 14, +} + +/** + * Describes the enum common.v1.SortField. + */ +export const SortFieldSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_sort, 0); + +/** + * Direction to sort results + * + * @generated from enum common.v1.SortDirection + */ +export enum SortDirection { + /** + * Unspecified direction - server defaults to ASC. + * + * @generated from enum value: SORT_DIRECTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Ascending order (A-Z, 0-9, lowest first) + * + * @generated from enum value: SORT_DIRECTION_ASC = 1; + */ + ASC = 1, + + /** + * Descending order (Z-A, 9-0, highest first) + * + * @generated from enum value: SORT_DIRECTION_DESC = 2; + */ + DESC = 2, +} + +/** + * Describes the enum common.v1.SortDirection. + */ +export const SortDirectionSchema: GenEnum = /*@__PURE__*/ enumDesc(file_common_v1_sort, 1); diff --git a/client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts b/client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts new file mode 100644 index 000000000..5396e7bf9 --- /dev/null +++ b/client/src/protoFleet/api/generated/device_set/v1/device_set_pb.ts @@ -0,0 +1,1768 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file device_set/v1/device_set.proto (package device_set.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { DeviceSelector } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { SortConfig } from "../../common/v1/sort_pb"; +import { file_common_v1_sort } from "../../common/v1/sort_pb"; +import type { ComponentType } from "../../errors/v1/errors_pb"; +import { file_errors_v1_errors } from "../../errors/v1/errors_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file device_set/v1/device_set.proto. + */ +export const file_device_set_v1_device_set: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch5kZXZpY2Vfc2V0L3YxL2RldmljZV9zZXQucHJvdG8SDWRldmljZV9zZXQudjEiywIKCURldmljZVNldBIKCgJpZBgBIAEoAxIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlEg0KBWxhYmVsGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhQKDGRldmljZV9jb3VudBgFIAEoBRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIsCglyYWNrX2luZm8YCCABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgJIAEoCzIYLmRldmljZV9zZXQudjEuR3JvdXBJbmZvSABCDgoMdHlwZV9kZXRhaWxzIrwBCghSYWNrSW5mbxIVCgRyb3dzGAEgASgFQge6SAQaAiAAEhgKB2NvbHVtbnMYAiABKAVCB7pIBBoCIAASFQoEem9uZRgDIAEoCUIHukgEcgIQARIyCgtvcmRlcl9pbmRleBgEIAEoDjIdLmRldmljZV9zZXQudjEuUmFja09yZGVySW5kZXgSNAoMY29vbGluZ190eXBlGAUgASgOMh4uZGV2aWNlX3NldC52MS5SYWNrQ29vbGluZ1R5cGUiCwoJR3JvdXBJbmZvIp4BCg9EZXZpY2VTZXRNZW1iZXISGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSLAoIYWRkZWRfYXQYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKBHJhY2sYAyABKAsyIC5kZXZpY2Vfc2V0LnYxLlJhY2tNZW1iZXJEZXRhaWxzSABCEAoObWVtYmVyX2RldGFpbHMiSwoRUmFja01lbWJlckRldGFpbHMSNgoNc2xvdF9wb3NpdGlvbhgBIAEoCzIfLmRldmljZV9zZXQudjEuUmFja1Nsb3RQb3NpdGlvbiJBChBSYWNrU2xvdFBvc2l0aW9uEhQKA3JvdxgBIAEoBUIHukgEGgIoABIXCgZjb2x1bW4YAiABKAVCB7pIBBoCKAAixwIKFkNyZWF0ZURldmljZVNldFJlcXVlc3QSNgoEdHlwZRgBIAEoDjIcLmRldmljZV9zZXQudjEuRGV2aWNlU2V0VHlwZUIKukgHggEEEAEgABIbCgVsYWJlbBgCIAEoCUIMukgJyAEBcgQQARhkEh0KC2Rlc2NyaXB0aW9uGAMgASgJQgi6SAVyAxj0AxIsCglyYWNrX2luZm8YBCABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgFIAEoCzIYLmRldmljZV9zZXQudjEuR3JvdXBJbmZvSAASNwoPZGV2aWNlX3NlbGVjdG9yGAYgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9ySAGIAQFCDgoMdHlwZV9kZXRhaWxzQhIKEF9kZXZpY2Vfc2VsZWN0b3IiXAoXQ3JlYXRlRGV2aWNlU2V0UmVzcG9uc2USLAoKZGV2aWNlX3NldBgBIAEoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0EhMKC2FkZGVkX2NvdW50GAIgASgFIjUKE0dldERldmljZVNldFJlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACJEChRHZXREZXZpY2VTZXRSZXNwb25zZRIsCgpkZXZpY2Vfc2V0GAEgASgLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQivQIKFlVwZGF0ZURldmljZVNldFJlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgABIgCgVsYWJlbBgCIAEoCUIMukgJ2AEBcgQQARhkSAGIAQESJQoLZGVzY3JpcHRpb24YAyABKAlCC7pICNgBAXIDGPQDSAKIAQESLAoJcmFja19pbmZvGAQgASgLMhcuZGV2aWNlX3NldC52MS5SYWNrSW5mb0gAEi4KCmdyb3VwX2luZm8YBSABKAsyGC5kZXZpY2Vfc2V0LnYxLkdyb3VwSW5mb0gAEjIKD2RldmljZV9zZWxlY3RvchgGIAEoCzIZLmNvbW1vbi52MS5EZXZpY2VTZWxlY3RvckIOCgx0eXBlX2RldGFpbHNCCAoGX2xhYmVsQg4KDF9kZXNjcmlwdGlvbiJHChdVcGRhdGVEZXZpY2VTZXRSZXNwb25zZRIsCgpkZXZpY2Vfc2V0GAEgASgLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQiOAoWRGVsZXRlRGV2aWNlU2V0UmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAIhkKF0RlbGV0ZURldmljZVNldFJlc3BvbnNlIuoBChVMaXN0RGV2aWNlU2V0c1JlcXVlc3QSNAoEdHlwZRgBIAEoDjIcLmRldmljZV9zZXQudjEuRGV2aWNlU2V0VHlwZUIIukgFggECEAESGgoJcGFnZV9zaXplGAIgASgFQge6SAQaAigAEhIKCnBhZ2VfdG9rZW4YAyABKAkSIwoEc29ydBgEIAEoCzIVLmNvbW1vbi52MS5Tb3J0Q29uZmlnEjcKFWVycm9yX2NvbXBvbmVudF90eXBlcxgFIAMoDjIYLmVycm9ycy52MS5Db21wb25lbnRUeXBlEg0KBXpvbmVzGAYgAygJInUKFkxpc3REZXZpY2VTZXRzUmVzcG9uc2USLQoLZGV2aWNlX3NldHMYASADKAsyGC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEwoLdG90YWxfY291bnQYAyABKAUiegocQWRkRGV2aWNlc1RvRGV2aWNlU2V0UmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEjoKD2RldmljZV9zZWxlY3RvchgCIAEoCzIZLmNvbW1vbi52MS5EZXZpY2VTZWxlY3RvckIGukgDyAEBIksKHUFkZERldmljZXNUb0RldmljZVNldFJlc3BvbnNlEhUKDWRldmljZV9zZXRfaWQYASABKAMSEwoLYWRkZWRfY291bnQYAiABKAUifwohUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXRSZXF1ZXN0Eh4KDWRldmljZV9zZXRfaWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiOwoiUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXRSZXNwb25zZRIVCg1yZW1vdmVkX2NvdW50GAEgASgFIm0KG0xpc3REZXZpY2VTZXRNZW1iZXJzUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABISCgpwYWdlX3Rva2VuGAMgASgJImgKHExpc3REZXZpY2VTZXRNZW1iZXJzUmVzcG9uc2USLwoHbWVtYmVycxgBIAMoCzIeLmRldmljZV9zZXQudjEuRGV2aWNlU2V0TWVtYmVyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSJsChpHZXREZXZpY2VEZXZpY2VTZXRzUmVxdWVzdBIiChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIHukgEcgIQARIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlIkwKG0dldERldmljZURldmljZVNldHNSZXNwb25zZRItCgtkZXZpY2Vfc2V0cxgBIAMoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IpsBChpTZXRSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABEjkKCHBvc2l0aW9uGAMgASgLMh8uZGV2aWNlX3NldC52MS5SYWNrU2xvdFBvc2l0aW9uQga6SAPIAQEiWwobU2V0UmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlEhUKDWRldmljZV9zZXRfaWQYASABKAMSJQoEc2xvdBgCIAEoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3QiYgocQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABIh8KHUNsZWFyUmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlIjUKE0dldFJhY2tTbG90c1JlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACJYCghSYWNrU2xvdBIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRIxCghwb3NpdGlvbhgCIAEoCzIfLmRldmljZV9zZXQudjEuUmFja1Nsb3RQb3NpdGlvbiI+ChRHZXRSYWNrU2xvdHNSZXNwb25zZRImCgVzbG90cxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3Qi7QQKDkRldmljZVNldFN0YXRzEhUKDWRldmljZV9zZXRfaWQYASABKAMSFAoMZGV2aWNlX2NvdW50GAIgASgFEhcKD3JlcG9ydGluZ19jb3VudBgDIAEoBRIaChJ0b3RhbF9oYXNocmF0ZV90aHMYBCABKAESGgoSYXZnX2VmZmljaWVuY3lfanRoGAUgASgBEhYKDnRvdGFsX3Bvd2VyX2t3GAYgASgBEhkKEW1pbl90ZW1wZXJhdHVyZV9jGAcgASgBEhkKEW1heF90ZW1wZXJhdHVyZV9jGAggASgBEhUKDWhhc2hpbmdfY291bnQYCSABKAUSFAoMYnJva2VuX2NvdW50GAogASgFEhUKDW9mZmxpbmVfY291bnQYCyABKAUSFgoOc2xlZXBpbmdfY291bnQYDCABKAUSIAoYaGFzaHJhdGVfcmVwb3J0aW5nX2NvdW50GA0gASgFEiIKGmVmZmljaWVuY3lfcmVwb3J0aW5nX2NvdW50GA4gASgFEh0KFXBvd2VyX3JlcG9ydGluZ19jb3VudBgPIAEoBRIjCht0ZW1wZXJhdHVyZV9yZXBvcnRpbmdfY291bnQYECABKAUSIQoZY29udHJvbF9ib2FyZF9pc3N1ZV9jb3VudBgRIAEoBRIXCg9mYW5faXNzdWVfY291bnQYEiABKAUSHgoWaGFzaF9ib2FyZF9pc3N1ZV9jb3VudBgTIAEoBRIXCg9wc3VfaXNzdWVfY291bnQYFCABKAUSNAoNc2xvdF9zdGF0dXNlcxgVIAMoCzIdLmRldmljZV9zZXQudjEuUmFja1Nsb3RTdGF0dXMiMgoYR2V0RGV2aWNlU2V0U3RhdHNSZXF1ZXN0EhYKDmRldmljZV9zZXRfaWRzGAEgAygDIkkKGUdldERldmljZVNldFN0YXRzUmVzcG9uc2USLAoFc3RhdHMYASADKAsyHS5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFN0YXRzIl4KDlJhY2tTbG90U3RhdHVzEgsKA3JvdxgBIAEoBRIOCgZjb2x1bW4YAiABKAUSLwoGc3RhdHVzGAMgASgOMh8uZGV2aWNlX3NldC52MS5TbG90RGV2aWNlU3RhdHVzIhYKFExpc3RSYWNrWm9uZXNSZXF1ZXN0IiYKFUxpc3RSYWNrWm9uZXNSZXNwb25zZRINCgV6b25lcxgBIAMoCSIWChRMaXN0UmFja1R5cGVzUmVxdWVzdCI9CghSYWNrVHlwZRIMCgRyb3dzGAEgASgFEg8KB2NvbHVtbnMYAiABKAUSEgoKcmFja19jb3VudBgDIAEoBSJEChVMaXN0UmFja1R5cGVzUmVzcG9uc2USKwoKcmFja190eXBlcxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1R5cGUiiwIKD1NhdmVSYWNrUmVxdWVzdBImCg1kZXZpY2Vfc2V0X2lkGAEgASgDQgq6SAfYAQEiAiAASACIAQESGwoFbGFiZWwYAiABKAlCDLpICcgBAXIEEAEYZBIyCglyYWNrX2luZm8YAyABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvQga6SAPIAQESOgoPZGV2aWNlX3NlbGVjdG9yGAQgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQESMQoQc2xvdF9hc3NpZ25tZW50cxgFIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3RCEAoOX2RldmljZV9zZXRfaWQiWAoQU2F2ZVJhY2tSZXNwb25zZRIsCgpkZXZpY2Vfc2V0GAEgASgLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQSFgoOYXNzaWduZWRfY291bnQYAiABKAUqZQoNRGV2aWNlU2V0VHlwZRIfChtERVZJQ0VfU0VUX1RZUEVfVU5TUEVDSUZJRUQQABIZChVERVZJQ0VfU0VUX1RZUEVfR1JPVVAQARIYChRERVZJQ0VfU0VUX1RZUEVfUkFDSxACKrYBCg5SYWNrT3JkZXJJbmRleBIgChxSQUNLX09SREVSX0lOREVYX1VOU1BFQ0lGSUVEEAASIAocUkFDS19PUkRFUl9JTkRFWF9CT1RUT01fTEVGVBABEh0KGVJBQ0tfT1JERVJfSU5ERVhfVE9QX0xFRlQQAhIhCh1SQUNLX09SREVSX0lOREVYX0JPVFRPTV9SSUdIVBADEh4KGlJBQ0tfT1JERVJfSU5ERVhfVE9QX1JJR0hUEAQqcAoPUmFja0Nvb2xpbmdUeXBlEiEKHVJBQ0tfQ09PTElOR19UWVBFX1VOU1BFQ0lGSUVEEAASGQoVUkFDS19DT09MSU5HX1RZUEVfQUlSEAESHwobUkFDS19DT09MSU5HX1RZUEVfSU1NRVJTSU9OEAIq3QEKEFNsb3REZXZpY2VTdGF0dXMSIgoeU0xPVF9ERVZJQ0VfU1RBVFVTX1VOU1BFQ0lGSUVEEAASHAoYU0xPVF9ERVZJQ0VfU1RBVFVTX0VNUFRZEAESHgoaU0xPVF9ERVZJQ0VfU1RBVFVTX0hFQUxUSFkQAhImCiJTTE9UX0RFVklDRV9TVEFUVVNfTkVFRFNfQVRURU5USU9OEAMSHgoaU0xPVF9ERVZJQ0VfU1RBVFVTX09GRkxJTkUQBBIfChtTTE9UX0RFVklDRV9TVEFUVVNfU0xFRVBJTkcQBTLvDAoQRGV2aWNlU2V0U2VydmljZRJgCg9DcmVhdGVEZXZpY2VTZXQSJS5kZXZpY2Vfc2V0LnYxLkNyZWF0ZURldmljZVNldFJlcXVlc3QaJi5kZXZpY2Vfc2V0LnYxLkNyZWF0ZURldmljZVNldFJlc3BvbnNlElcKDEdldERldmljZVNldBIiLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0UmVxdWVzdBojLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0UmVzcG9uc2USYAoPVXBkYXRlRGV2aWNlU2V0EiUuZGV2aWNlX3NldC52MS5VcGRhdGVEZXZpY2VTZXRSZXF1ZXN0GiYuZGV2aWNlX3NldC52MS5VcGRhdGVEZXZpY2VTZXRSZXNwb25zZRJgCg9EZWxldGVEZXZpY2VTZXQSJS5kZXZpY2Vfc2V0LnYxLkRlbGV0ZURldmljZVNldFJlcXVlc3QaJi5kZXZpY2Vfc2V0LnYxLkRlbGV0ZURldmljZVNldFJlc3BvbnNlEl0KDkxpc3REZXZpY2VTZXRzEiQuZGV2aWNlX3NldC52MS5MaXN0RGV2aWNlU2V0c1JlcXVlc3QaJS5kZXZpY2Vfc2V0LnYxLkxpc3REZXZpY2VTZXRzUmVzcG9uc2UScgoVQWRkRGV2aWNlc1RvRGV2aWNlU2V0EisuZGV2aWNlX3NldC52MS5BZGREZXZpY2VzVG9EZXZpY2VTZXRSZXF1ZXN0GiwuZGV2aWNlX3NldC52MS5BZGREZXZpY2VzVG9EZXZpY2VTZXRSZXNwb25zZRKBAQoaUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXQSMC5kZXZpY2Vfc2V0LnYxLlJlbW92ZURldmljZXNGcm9tRGV2aWNlU2V0UmVxdWVzdBoxLmRldmljZV9zZXQudjEuUmVtb3ZlRGV2aWNlc0Zyb21EZXZpY2VTZXRSZXNwb25zZRJvChRMaXN0RGV2aWNlU2V0TWVtYmVycxIqLmRldmljZV9zZXQudjEuTGlzdERldmljZVNldE1lbWJlcnNSZXF1ZXN0GisuZGV2aWNlX3NldC52MS5MaXN0RGV2aWNlU2V0TWVtYmVyc1Jlc3BvbnNlEmwKE0dldERldmljZURldmljZVNldHMSKS5kZXZpY2Vfc2V0LnYxLkdldERldmljZURldmljZVNldHNSZXF1ZXN0GiouZGV2aWNlX3NldC52MS5HZXREZXZpY2VEZXZpY2VTZXRzUmVzcG9uc2USbAoTU2V0UmFja1Nsb3RQb3NpdGlvbhIpLmRldmljZV9zZXQudjEuU2V0UmFja1Nsb3RQb3NpdGlvblJlcXVlc3QaKi5kZXZpY2Vfc2V0LnYxLlNldFJhY2tTbG90UG9zaXRpb25SZXNwb25zZRJyChVDbGVhclJhY2tTbG90UG9zaXRpb24SKy5kZXZpY2Vfc2V0LnYxLkNsZWFyUmFja1Nsb3RQb3NpdGlvblJlcXVlc3QaLC5kZXZpY2Vfc2V0LnYxLkNsZWFyUmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlElcKDEdldFJhY2tTbG90cxIiLmRldmljZV9zZXQudjEuR2V0UmFja1Nsb3RzUmVxdWVzdBojLmRldmljZV9zZXQudjEuR2V0UmFja1Nsb3RzUmVzcG9uc2USZgoRR2V0RGV2aWNlU2V0U3RhdHMSJy5kZXZpY2Vfc2V0LnYxLkdldERldmljZVNldFN0YXRzUmVxdWVzdBooLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0U3RhdHNSZXNwb25zZRJaCg1MaXN0UmFja1pvbmVzEiMuZGV2aWNlX3NldC52MS5MaXN0UmFja1pvbmVzUmVxdWVzdBokLmRldmljZV9zZXQudjEuTGlzdFJhY2tab25lc1Jlc3BvbnNlEloKDUxpc3RSYWNrVHlwZXMSIy5kZXZpY2Vfc2V0LnYxLkxpc3RSYWNrVHlwZXNSZXF1ZXN0GiQuZGV2aWNlX3NldC52MS5MaXN0UmFja1R5cGVzUmVzcG9uc2USSwoIU2F2ZVJhY2sSHi5kZXZpY2Vfc2V0LnYxLlNhdmVSYWNrUmVxdWVzdBofLmRldmljZV9zZXQudjEuU2F2ZVJhY2tSZXNwb25zZULDAQoRY29tLmRldmljZV9zZXQudjFCDkRldmljZVNldFByb3RvUAFaTWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2RldmljZV9zZXQvdjE7ZGV2aWNlX3NldHYxogIDRFhYqgIMRGV2aWNlU2V0LlYxygIMRGV2aWNlU2V0XFYx4gIYRGV2aWNlU2V0XFYxXEdQQk1ldGFkYXRh6gINRGV2aWNlU2V0OjpWMWIGcHJvdG8z", + [ + file_google_protobuf_timestamp, + file_buf_validate_validate, + file_common_v1_device_selector, + file_common_v1_sort, + file_errors_v1_errors, + ], + ); + +/** + * DeviceSet represents a group or rack of devices + * + * @generated from message device_set.v1.DeviceSet + */ +export type DeviceSet = Message<"device_set.v1.DeviceSet"> & { + /** + * Unique identifier for the device set + * + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * Type of device set (group or rack) + * + * @generated from field: device_set.v1.DeviceSetType type = 2; + */ + type: DeviceSetType; + + /** + * Human-readable label for the device set + * + * @generated from field: string label = 3; + */ + label: string; + + /** + * Optional description of the device set's purpose + * + * @generated from field: string description = 4; + */ + description: string; + + /** + * Number of devices in this device set + * + * @generated from field: int32 device_count = 5; + */ + deviceCount: number; + + /** + * When the device set was created + * + * @generated from field: google.protobuf.Timestamp created_at = 6; + */ + createdAt?: Timestamp; + + /** + * When the device set was last updated + * + * @generated from field: google.protobuf.Timestamp updated_at = 7; + */ + updatedAt?: Timestamp; + + /** + * Type-specific metadata (enforces only one detail type is set) + * + * @generated from oneof device_set.v1.DeviceSet.type_details + */ + typeDetails: + | { + /** + * @generated from field: device_set.v1.RackInfo rack_info = 8; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: device_set.v1.GroupInfo group_info = 9; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message device_set.v1.DeviceSet. + * Use `create(DeviceSetSchema)` to create a new message. + */ +export const DeviceSetSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 0); + +/** + * Rack-specific metadata for rack-type device sets + * + * @generated from message device_set.v1.RackInfo + */ +export type RackInfo = Message<"device_set.v1.RackInfo"> & { + /** + * Number of rows in the rack grid + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns in the rack grid + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Physical zone description (e.g. building, room, area) + * + * @generated from field: string zone = 3; + */ + zone: string; + + /** + * Order index defining where numbering starts + * + * @generated from field: device_set.v1.RackOrderIndex order_index = 4; + */ + orderIndex: RackOrderIndex; + + /** + * Cooling type for this rack + * + * @generated from field: device_set.v1.RackCoolingType cooling_type = 5; + */ + coolingType: RackCoolingType; +}; + +/** + * Describes the message device_set.v1.RackInfo. + * Use `create(RackInfoSchema)` to create a new message. + */ +export const RackInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 1); + +/** + * Group-specific metadata for group-type device sets + * Reserved for future group-specific fields (e.g., tags, policies) + * + * @generated from message device_set.v1.GroupInfo + */ +export type GroupInfo = Message<"device_set.v1.GroupInfo"> & {}; + +/** + * Describes the message device_set.v1.GroupInfo. + * Use `create(GroupInfoSchema)` to create a new message. + */ +export const GroupInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 2); + +/** + * DeviceSetMember represents a device in a device set + * + * @generated from message device_set.v1.DeviceSetMember + */ +export type DeviceSetMember = Message<"device_set.v1.DeviceSetMember"> & { + /** + * Device identifier of the member + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * When the device was added to the device set + * + * @generated from field: google.protobuf.Timestamp added_at = 2; + */ + addedAt?: Timestamp; + + /** + * Type-specific member details + * + * @generated from oneof device_set.v1.DeviceSetMember.member_details + */ + memberDetails: + | { + /** + * @generated from field: device_set.v1.RackMemberDetails rack = 3; + */ + value: RackMemberDetails; + case: "rack"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message device_set.v1.DeviceSetMember. + * Use `create(DeviceSetMemberSchema)` to create a new message. + */ +export const DeviceSetMemberSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 3); + +/** + * Rack-specific details for a device set member + * + * @generated from message device_set.v1.RackMemberDetails + */ +export type RackMemberDetails = Message<"device_set.v1.RackMemberDetails"> & { + /** + * Slot position of the device within the rack + * + * @generated from field: device_set.v1.RackSlotPosition slot_position = 1; + */ + slotPosition?: RackSlotPosition; +}; + +/** + * Describes the message device_set.v1.RackMemberDetails. + * Use `create(RackMemberDetailsSchema)` to create a new message. + */ +export const RackMemberDetailsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 4); + +/** + * Position of a device within a rack + * + * @generated from message device_set.v1.RackSlotPosition + */ +export type RackSlotPosition = Message<"device_set.v1.RackSlotPosition"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; +}; + +/** + * Describes the message device_set.v1.RackSlotPosition. + * Use `create(RackSlotPositionSchema)` to create a new message. + */ +export const RackSlotPositionSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 5); + +/** + * Request to create a new device set + * + * @generated from message device_set.v1.CreateDeviceSetRequest + */ +export type CreateDeviceSetRequest = Message<"device_set.v1.CreateDeviceSetRequest"> & { + /** + * Type of device set to create (required) + * + * @generated from field: device_set.v1.DeviceSetType type = 1; + */ + type: DeviceSetType; + + /** + * Label for the device set (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Optional description (max 500 characters) + * + * @generated from field: string description = 3; + */ + description: string; + + /** + * Type-specific metadata (rack_info required for racks, group_info optional for groups) + * + * @generated from oneof device_set.v1.CreateDeviceSetRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: device_set.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: device_set.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: devices to add atomically when creating the device set. + * If provided, devices are added in the same transaction as device set creation. + * + * @generated from field: optional common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.CreateDeviceSetRequest. + * Use `create(CreateDeviceSetRequestSchema)` to create a new message. + */ +export const CreateDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 6); + +/** + * Response after creating a device set + * + * @generated from message device_set.v1.CreateDeviceSetResponse + */ +export type CreateDeviceSetResponse = Message<"device_set.v1.CreateDeviceSetResponse"> & { + /** + * The newly created device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; + + /** + * Number of devices added to the device set (0 if no device_selector was provided) + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message device_set.v1.CreateDeviceSetResponse. + * Use `create(CreateDeviceSetResponseSchema)` to create a new message. + */ +export const CreateDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 7); + +/** + * Request to get a device set by ID + * + * @generated from message device_set.v1.GetDeviceSetRequest + */ +export type GetDeviceSetRequest = Message<"device_set.v1.GetDeviceSetRequest"> & { + /** + * ID of the device set to retrieve + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetRequest. + * Use `create(GetDeviceSetRequestSchema)` to create a new message. + */ +export const GetDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 8); + +/** + * Response containing the requested device set + * + * @generated from message device_set.v1.GetDeviceSetResponse + */ +export type GetDeviceSetResponse = Message<"device_set.v1.GetDeviceSetResponse"> & { + /** + * The requested device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetResponse. + * Use `create(GetDeviceSetResponseSchema)` to create a new message. + */ +export const GetDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 9); + +/** + * Request to update a device set + * + * @generated from message device_set.v1.UpdateDeviceSetRequest + */ +export type UpdateDeviceSetRequest = Message<"device_set.v1.UpdateDeviceSetRequest"> & { + /** + * ID of the device set to update + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * New label (optional, 1-100 characters if provided) + * + * @generated from field: optional string label = 2; + */ + label?: string; + + /** + * New description (optional, max 500 characters if provided). + * Omit the field to leave unchanged; set to empty string to clear. + * + * @generated from field: optional string description = 3; + */ + description?: string; + + /** + * Type-specific metadata updates (only applicable for the device set's type) + * + * @generated from oneof device_set.v1.UpdateDeviceSetRequest.type_details + */ + typeDetails: + | { + /** + * @generated from field: device_set.v1.RackInfo rack_info = 4; + */ + value: RackInfo; + case: "rackInfo"; + } + | { + /** + * @generated from field: device_set.v1.GroupInfo group_info = 5; + */ + value: GroupInfo; + case: "groupInfo"; + } + | { case: undefined; value?: undefined }; + + /** + * Optional: atomically replace all device set members with the selected devices. + * + * @generated from field: common.v1.DeviceSelector device_selector = 6; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.UpdateDeviceSetRequest. + * Use `create(UpdateDeviceSetRequestSchema)` to create a new message. + */ +export const UpdateDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 10); + +/** + * Response after updating a device set + * + * @generated from message device_set.v1.UpdateDeviceSetResponse + */ +export type UpdateDeviceSetResponse = Message<"device_set.v1.UpdateDeviceSetResponse"> & { + /** + * The updated device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; +}; + +/** + * Describes the message device_set.v1.UpdateDeviceSetResponse. + * Use `create(UpdateDeviceSetResponseSchema)` to create a new message. + */ +export const UpdateDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 11); + +/** + * Request to delete a device set + * + * @generated from message device_set.v1.DeleteDeviceSetRequest + */ +export type DeleteDeviceSetRequest = Message<"device_set.v1.DeleteDeviceSetRequest"> & { + /** + * ID of the device set to delete + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; +}; + +/** + * Describes the message device_set.v1.DeleteDeviceSetRequest. + * Use `create(DeleteDeviceSetRequestSchema)` to create a new message. + */ +export const DeleteDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 12); + +/** + * Response after deleting a device set + * + * Empty response - success indicated by gRPC status + * + * @generated from message device_set.v1.DeleteDeviceSetResponse + */ +export type DeleteDeviceSetResponse = Message<"device_set.v1.DeleteDeviceSetResponse"> & {}; + +/** + * Describes the message device_set.v1.DeleteDeviceSetResponse. + * Use `create(DeleteDeviceSetResponseSchema)` to create a new message. + */ +export const DeleteDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 13); + +/** + * Request to list all device sets + * + * @generated from message device_set.v1.ListDeviceSetsRequest + */ +export type ListDeviceSetsRequest = Message<"device_set.v1.ListDeviceSetsRequest"> & { + /** + * Filter by device set type (optional, returns all types if unspecified) + * + * @generated from field: device_set.v1.DeviceSetType type = 1; + */ + type: DeviceSetType; + + /** + * Maximum number of device sets to return (0 = server default) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; + + /** + * Sort configuration (defaults to name ascending). + * Supported fields: SORT_FIELD_NAME, SORT_FIELD_DEVICE_COUNT, SORT_FIELD_ISSUE_COUNT. + * + * @generated from field: common.v1.SortConfig sort = 4; + */ + sort?: SortConfig; + + /** + * Filter by device sets containing devices with open errors of these component types. + * When non-empty, only device sets with at least one device having an open error + * matching any of the specified component types are returned. + * + * @generated from field: repeated errors.v1.ComponentType error_component_types = 5; + */ + errorComponentTypes: ComponentType[]; + + /** + * Filter by rack zones. Only valid when type is RACK. + * When non-empty, only racks in any of the specified zones are returned. + * + * @generated from field: repeated string zones = 6; + */ + zones: string[]; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetsRequest. + * Use `create(ListDeviceSetsRequestSchema)` to create a new message. + */ +export const ListDeviceSetsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 14); + +/** + * Response containing device sets + * + * @generated from message device_set.v1.ListDeviceSetsResponse + */ +export type ListDeviceSetsResponse = Message<"device_set.v1.ListDeviceSetsResponse"> & { + /** + * List of device sets ordered by label + * + * @generated from field: repeated device_set.v1.DeviceSet device_sets = 1; + */ + deviceSets: DeviceSet[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Total number of device sets matching the request filters + * + * @generated from field: int32 total_count = 3; + */ + totalCount: number; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetsResponse. + * Use `create(ListDeviceSetsResponseSchema)` to create a new message. + */ +export const ListDeviceSetsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 15); + +/** + * Request to add devices to a device set + * + * @generated from message device_set.v1.AddDevicesToDeviceSetRequest + */ +export type AddDevicesToDeviceSetRequest = Message<"device_set.v1.AddDevicesToDeviceSetRequest"> & { + /** + * ID of the device set to add devices to + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Devices to add: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.AddDevicesToDeviceSetRequest. + * Use `create(AddDevicesToDeviceSetRequestSchema)` to create a new message. + */ +export const AddDevicesToDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 16); + +/** + * Response after adding devices to a device set + * + * @generated from message device_set.v1.AddDevicesToDeviceSetResponse + */ +export type AddDevicesToDeviceSetResponse = Message<"device_set.v1.AddDevicesToDeviceSetResponse"> & { + /** + * ID of the device set devices were added to + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Number of devices successfully added + * May be less than requested if some devices were already members + * + * @generated from field: int32 added_count = 2; + */ + addedCount: number; +}; + +/** + * Describes the message device_set.v1.AddDevicesToDeviceSetResponse. + * Use `create(AddDevicesToDeviceSetResponseSchema)` to create a new message. + */ +export const AddDevicesToDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 17); + +/** + * Request to remove devices from a device set + * + * @generated from message device_set.v1.RemoveDevicesFromDeviceSetRequest + */ +export type RemoveDevicesFromDeviceSetRequest = Message<"device_set.v1.RemoveDevicesFromDeviceSetRequest"> & { + /** + * ID of the device set to remove devices from + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Devices to remove: specific list or all paired devices + * + * @generated from field: common.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message device_set.v1.RemoveDevicesFromDeviceSetRequest. + * Use `create(RemoveDevicesFromDeviceSetRequestSchema)` to create a new message. + */ +export const RemoveDevicesFromDeviceSetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 18); + +/** + * Response after removing devices from a device set + * + * @generated from message device_set.v1.RemoveDevicesFromDeviceSetResponse + */ +export type RemoveDevicesFromDeviceSetResponse = Message<"device_set.v1.RemoveDevicesFromDeviceSetResponse"> & { + /** + * Number of devices successfully removed + * + * @generated from field: int32 removed_count = 1; + */ + removedCount: number; +}; + +/** + * Describes the message device_set.v1.RemoveDevicesFromDeviceSetResponse. + * Use `create(RemoveDevicesFromDeviceSetResponseSchema)` to create a new message. + */ +export const RemoveDevicesFromDeviceSetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 19); + +/** + * Request to list members of a device set + * + * @generated from message device_set.v1.ListDeviceSetMembersRequest + */ +export type ListDeviceSetMembersRequest = Message<"device_set.v1.ListDeviceSetMembersRequest"> & { + /** + * ID of the device set to list members for + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Maximum number of members to return (default: all) + * + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * Pagination cursor from a previous response + * + * @generated from field: string page_token = 3; + */ + pageToken: string; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetMembersRequest. + * Use `create(ListDeviceSetMembersRequestSchema)` to create a new message. + */ +export const ListDeviceSetMembersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 20); + +/** + * Response containing device set members + * + * @generated from message device_set.v1.ListDeviceSetMembersResponse + */ +export type ListDeviceSetMembersResponse = Message<"device_set.v1.ListDeviceSetMembersResponse"> & { + /** + * List of members ordered by when they were added (newest first) + * + * @generated from field: repeated device_set.v1.DeviceSetMember members = 1; + */ + members: DeviceSetMember[]; + + /** + * Cursor for the next page, empty if no more results + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message device_set.v1.ListDeviceSetMembersResponse. + * Use `create(ListDeviceSetMembersResponseSchema)` to create a new message. + */ +export const ListDeviceSetMembersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 21); + +/** + * Request to get device sets for a device + * + * @generated from message device_set.v1.GetDeviceDeviceSetsRequest + */ +export type GetDeviceDeviceSetsRequest = Message<"device_set.v1.GetDeviceDeviceSetsRequest"> & { + /** + * Device identifier to look up + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Filter by device set type (optional, returns all types if unspecified) + * + * @generated from field: device_set.v1.DeviceSetType type = 2; + */ + type: DeviceSetType; +}; + +/** + * Describes the message device_set.v1.GetDeviceDeviceSetsRequest. + * Use `create(GetDeviceDeviceSetsRequestSchema)` to create a new message. + */ +export const GetDeviceDeviceSetsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 22); + +/** + * Response containing device sets the device belongs to + * + * @generated from message device_set.v1.GetDeviceDeviceSetsResponse + */ +export type GetDeviceDeviceSetsResponse = Message<"device_set.v1.GetDeviceDeviceSetsResponse"> & { + /** + * Device sets the device belongs to, ordered by label + * + * @generated from field: repeated device_set.v1.DeviceSet device_sets = 1; + */ + deviceSets: DeviceSet[]; +}; + +/** + * Describes the message device_set.v1.GetDeviceDeviceSetsResponse. + * Use `create(GetDeviceDeviceSetsResponseSchema)` to create a new message. + */ +export const GetDeviceDeviceSetsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 23); + +/** + * Request to set a device's slot position within a rack + * + * @generated from message device_set.v1.SetRackSlotPositionRequest + */ +export type SetRackSlotPositionRequest = Message<"device_set.v1.SetRackSlotPositionRequest"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Device to position + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; + + /** + * Target slot position + * + * @generated from field: device_set.v1.RackSlotPosition position = 3; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message device_set.v1.SetRackSlotPositionRequest. + * Use `create(SetRackSlotPositionRequestSchema)` to create a new message. + */ +export const SetRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 24); + +/** + * Response after setting a rack slot position + * + * @generated from message device_set.v1.SetRackSlotPositionResponse + */ +export type SetRackSlotPositionResponse = Message<"device_set.v1.SetRackSlotPositionResponse"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * The slot that was set + * + * @generated from field: device_set.v1.RackSlot slot = 2; + */ + slot?: RackSlot; +}; + +/** + * Describes the message device_set.v1.SetRackSlotPositionResponse. + * Use `create(SetRackSlotPositionResponseSchema)` to create a new message. + */ +export const SetRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 25); + +/** + * Request to clear a device's slot position within a rack + * + * @generated from message device_set.v1.ClearRackSlotPositionRequest + */ +export type ClearRackSlotPositionRequest = Message<"device_set.v1.ClearRackSlotPositionRequest"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Device to unposition + * + * @generated from field: string device_identifier = 2; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message device_set.v1.ClearRackSlotPositionRequest. + * Use `create(ClearRackSlotPositionRequestSchema)` to create a new message. + */ +export const ClearRackSlotPositionRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 26); + +/** + * Response after clearing a rack slot position + * + * @generated from message device_set.v1.ClearRackSlotPositionResponse + */ +export type ClearRackSlotPositionResponse = Message<"device_set.v1.ClearRackSlotPositionResponse"> & {}; + +/** + * Describes the message device_set.v1.ClearRackSlotPositionResponse. + * Use `create(ClearRackSlotPositionResponseSchema)` to create a new message. + */ +export const ClearRackSlotPositionResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 27); + +/** + * Request to list all occupied slots in a rack + * + * @generated from message device_set.v1.GetRackSlotsRequest + */ +export type GetRackSlotsRequest = Message<"device_set.v1.GetRackSlotsRequest"> & { + /** + * ID of the rack device set + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; +}; + +/** + * Describes the message device_set.v1.GetRackSlotsRequest. + * Use `create(GetRackSlotsRequestSchema)` to create a new message. + */ +export const GetRackSlotsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 28); + +/** + * Represents a device assigned to a specific slot in a rack + * + * @generated from message device_set.v1.RackSlot + */ +export type RackSlot = Message<"device_set.v1.RackSlot"> & { + /** + * Device in this slot + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Slot position within the rack + * + * @generated from field: device_set.v1.RackSlotPosition position = 2; + */ + position?: RackSlotPosition; +}; + +/** + * Describes the message device_set.v1.RackSlot. + * Use `create(RackSlotSchema)` to create a new message. + */ +export const RackSlotSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 29); + +/** + * Response containing all occupied rack slots + * + * @generated from message device_set.v1.GetRackSlotsResponse + */ +export type GetRackSlotsResponse = Message<"device_set.v1.GetRackSlotsResponse"> & { + /** + * Occupied slots ordered by row then column + * + * @generated from field: repeated device_set.v1.RackSlot slots = 1; + */ + slots: RackSlot[]; +}; + +/** + * Describes the message device_set.v1.GetRackSlotsResponse. + * Use `create(GetRackSlotsResponseSchema)` to create a new message. + */ +export const GetRackSlotsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 30); + +/** + * Aggregated telemetry stats for a single device set + * + * @generated from message device_set.v1.DeviceSetStats + */ +export type DeviceSetStats = Message<"device_set.v1.DeviceSetStats"> & { + /** + * Device set identifier + * + * @generated from field: int64 device_set_id = 1; + */ + deviceSetId: bigint; + + /** + * Total number of devices in the device set + * + * @generated from field: int32 device_count = 2; + */ + deviceCount: number; + + /** + * Number of devices with recent telemetry data + * + * @generated from field: int32 reporting_count = 3; + */ + reportingCount: number; + + /** + * Aggregated telemetry (totals/averages across reporting devices) + * + * @generated from field: double total_hashrate_ths = 4; + */ + totalHashrateThs: number; + + /** + * @generated from field: double avg_efficiency_jth = 5; + */ + avgEfficiencyJth: number; + + /** + * @generated from field: double total_power_kw = 6; + */ + totalPowerKw: number; + + /** + * @generated from field: double min_temperature_c = 7; + */ + minTemperatureC: number; + + /** + * @generated from field: double max_temperature_c = 8; + */ + maxTemperatureC: number; + + /** + * Fleet health state counts (mirrors dashboard FleetHealth buckets) + * + * ACTIVE + no auth issues + no actionable errors + * + * @generated from field: int32 hashing_count = 9; + */ + hashingCount: number; + + /** + * ERROR/NEEDS_MINING_POOL/AUTH_NEEDED or has open errors + * + * @generated from field: int32 broken_count = 10; + */ + brokenCount: number; + + /** + * OFFLINE or NULL status + * + * @generated from field: int32 offline_count = 11; + */ + offlineCount: number; + + /** + * MAINTENANCE or INACTIVE + * + * @generated from field: int32 sleeping_count = 12; + */ + sleepingCount: number; + + /** + * Per-metric reporting counts (devices that report each specific metric) + * + * @generated from field: int32 hashrate_reporting_count = 13; + */ + hashrateReportingCount: number; + + /** + * @generated from field: int32 efficiency_reporting_count = 14; + */ + efficiencyReportingCount: number; + + /** + * @generated from field: int32 power_reporting_count = 15; + */ + powerReportingCount: number; + + /** + * @generated from field: int32 temperature_reporting_count = 16; + */ + temperatureReportingCount: number; + + /** + * Component issue counts (number of devices with open errors by component type) + * + * @generated from field: int32 control_board_issue_count = 17; + */ + controlBoardIssueCount: number; + + /** + * @generated from field: int32 fan_issue_count = 18; + */ + fanIssueCount: number; + + /** + * @generated from field: int32 hash_board_issue_count = 19; + */ + hashBoardIssueCount: number; + + /** + * @generated from field: int32 psu_issue_count = 20; + */ + psuIssueCount: number; + + /** + * Per-slot device status for rack-type device sets (empty for groups). + * Contains one entry per row x column position, including empty slots. + * + * @generated from field: repeated device_set.v1.RackSlotStatus slot_statuses = 21; + */ + slotStatuses: RackSlotStatus[]; +}; + +/** + * Describes the message device_set.v1.DeviceSetStats. + * Use `create(DeviceSetStatsSchema)` to create a new message. + */ +export const DeviceSetStatsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 31); + +/** + * Request to get aggregated stats for device sets + * + * @generated from message device_set.v1.GetDeviceSetStatsRequest + */ +export type GetDeviceSetStatsRequest = Message<"device_set.v1.GetDeviceSetStatsRequest"> & { + /** + * Device set IDs to get stats for + * + * @generated from field: repeated int64 device_set_ids = 1; + */ + deviceSetIds: bigint[]; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetStatsRequest. + * Use `create(GetDeviceSetStatsRequestSchema)` to create a new message. + */ +export const GetDeviceSetStatsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 32); + +/** + * Response containing stats for each requested device set + * + * @generated from message device_set.v1.GetDeviceSetStatsResponse + */ +export type GetDeviceSetStatsResponse = Message<"device_set.v1.GetDeviceSetStatsResponse"> & { + /** + * Stats per device set (one entry per requested device set ID) + * + * @generated from field: repeated device_set.v1.DeviceSetStats stats = 1; + */ + stats: DeviceSetStats[]; +}; + +/** + * Describes the message device_set.v1.GetDeviceSetStatsResponse. + * Use `create(GetDeviceSetStatsResponseSchema)` to create a new message. + */ +export const GetDeviceSetStatsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 33); + +/** + * Status of a single slot in a rack grid + * + * @generated from message device_set.v1.RackSlotStatus + */ +export type RackSlotStatus = Message<"device_set.v1.RackSlotStatus"> & { + /** + * Row position (0-indexed) + * + * @generated from field: int32 row = 1; + */ + row: number; + + /** + * Column position (0-indexed) + * + * @generated from field: int32 column = 2; + */ + column: number; + + /** + * Device status for this slot + * + * @generated from field: device_set.v1.SlotDeviceStatus status = 3; + */ + status: SlotDeviceStatus; +}; + +/** + * Describes the message device_set.v1.RackSlotStatus. + * Use `create(RackSlotStatusSchema)` to create a new message. + */ +export const RackSlotStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 34); + +/** + * Request to list all distinct rack zones for the organization + * + * @generated from message device_set.v1.ListRackZonesRequest + */ +export type ListRackZonesRequest = Message<"device_set.v1.ListRackZonesRequest"> & {}; + +/** + * Describes the message device_set.v1.ListRackZonesRequest. + * Use `create(ListRackZonesRequestSchema)` to create a new message. + */ +export const ListRackZonesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 35); + +/** + * Response containing all distinct rack zones + * + * @generated from message device_set.v1.ListRackZonesResponse + */ +export type ListRackZonesResponse = Message<"device_set.v1.ListRackZonesResponse"> & { + /** + * Distinct zone strings across all racks, sorted alphabetically + * + * @generated from field: repeated string zones = 1; + */ + zones: string[]; +}; + +/** + * Describes the message device_set.v1.ListRackZonesResponse. + * Use `create(ListRackZonesResponseSchema)` to create a new message. + */ +export const ListRackZonesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 36); + +/** + * Request to list all distinct rack types for the organization + * + * @generated from message device_set.v1.ListRackTypesRequest + */ +export type ListRackTypesRequest = Message<"device_set.v1.ListRackTypesRequest"> & {}; + +/** + * Describes the message device_set.v1.ListRackTypesRequest. + * Use `create(ListRackTypesRequestSchema)` to create a new message. + */ +export const ListRackTypesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 37); + +/** + * A rack type defined by its row/column dimensions and how many racks use it + * + * @generated from message device_set.v1.RackType + */ +export type RackType = Message<"device_set.v1.RackType"> & { + /** + * Number of rows + * + * @generated from field: int32 rows = 1; + */ + rows: number; + + /** + * Number of columns + * + * @generated from field: int32 columns = 2; + */ + columns: number; + + /** + * Number of racks using this layout + * + * @generated from field: int32 rack_count = 3; + */ + rackCount: number; +}; + +/** + * Describes the message device_set.v1.RackType. + * Use `create(RackTypeSchema)` to create a new message. + */ +export const RackTypeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_device_set_v1_device_set, 38); + +/** + * Response containing all distinct rack types + * + * @generated from message device_set.v1.ListRackTypesResponse + */ +export type ListRackTypesResponse = Message<"device_set.v1.ListRackTypesResponse"> & { + /** + * Distinct rack types ordered by most recently created rack using that layout + * + * @generated from field: repeated device_set.v1.RackType rack_types = 1; + */ + rackTypes: RackType[]; +}; + +/** + * Describes the message device_set.v1.ListRackTypesResponse. + * Use `create(ListRackTypesResponseSchema)` to create a new message. + */ +export const ListRackTypesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 39); + +/** + * Request to atomically create or update a rack with membership and slot assignments. + * + * @generated from message device_set.v1.SaveRackRequest + */ +export type SaveRackRequest = Message<"device_set.v1.SaveRackRequest"> & { + /** + * ID of an existing rack to update. Omit to create a new rack. + * + * @generated from field: optional int64 device_set_id = 1; + */ + deviceSetId?: bigint; + + /** + * Label for the rack (required, 1-100 characters) + * + * @generated from field: string label = 2; + */ + label: string; + + /** + * Rack-specific metadata (required) + * + * @generated from field: device_set.v1.RackInfo rack_info = 3; + */ + rackInfo?: RackInfo; + + /** + * Devices that should be members of this rack. + * Replaces all existing members atomically. + * + * @generated from field: common.v1.DeviceSelector device_selector = 4; + */ + deviceSelector?: DeviceSelector; + + /** + * Slot assignments for devices within the rack. + * Only devices included in device_selector may be assigned slots. + * Devices not listed here will be members without a slot position. + * + * @generated from field: repeated device_set.v1.RackSlot slot_assignments = 5; + */ + slotAssignments: RackSlot[]; +}; + +/** + * Describes the message device_set.v1.SaveRackRequest. + * Use `create(SaveRackRequestSchema)` to create a new message. + */ +export const SaveRackRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 40); + +/** + * Response after saving a rack. + * + * @generated from message device_set.v1.SaveRackResponse + */ +export type SaveRackResponse = Message<"device_set.v1.SaveRackResponse"> & { + /** + * The created or updated rack device set + * + * @generated from field: device_set.v1.DeviceSet device_set = 1; + */ + deviceSet?: DeviceSet; + + /** + * Number of slot positions assigned + * + * @generated from field: int32 assigned_count = 2; + */ + assignedCount: number; +}; + +/** + * Describes the message device_set.v1.SaveRackResponse. + * Use `create(SaveRackResponseSchema)` to create a new message. + */ +export const SaveRackResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_device_set_v1_device_set, 41); + +/** + * Type of device set + * + * @generated from enum device_set.v1.DeviceSetType + */ +export enum DeviceSetType { + /** + * Unspecified type - returns all types when filtering + * + * @generated from enum value: DEVICE_SET_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Group: many-to-many relationship (device can belong to multiple groups) + * + * @generated from enum value: DEVICE_SET_TYPE_GROUP = 1; + */ + GROUP = 1, + + /** + * Rack: one-to-one relationship (device can only be in one rack) + * + * @generated from enum value: DEVICE_SET_TYPE_RACK = 2; + */ + RACK = 2, +} + +/** + * Describes the enum device_set.v1.DeviceSetType. + */ +export const DeviceSetTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_device_set_v1_device_set, 0); + +/** + * Order index defining where row/column numbering starts in a rack + * + * @generated from enum device_set.v1.RackOrderIndex + */ +export enum RackOrderIndex { + /** + * @generated from enum value: RACK_ORDER_INDEX_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_LEFT = 1; + */ + BOTTOM_LEFT = 1, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_LEFT = 2; + */ + TOP_LEFT = 2, + + /** + * @generated from enum value: RACK_ORDER_INDEX_BOTTOM_RIGHT = 3; + */ + BOTTOM_RIGHT = 3, + + /** + * @generated from enum value: RACK_ORDER_INDEX_TOP_RIGHT = 4; + */ + TOP_RIGHT = 4, +} + +/** + * Describes the enum device_set.v1.RackOrderIndex. + */ +export const RackOrderIndexSchema: GenEnum = /*@__PURE__*/ enumDesc(file_device_set_v1_device_set, 1); + +/** + * Cooling type for a rack + * + * @generated from enum device_set.v1.RackCoolingType + */ +export enum RackCoolingType { + /** + * @generated from enum value: RACK_COOLING_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RACK_COOLING_TYPE_AIR = 1; + */ + AIR = 1, + + /** + * @generated from enum value: RACK_COOLING_TYPE_IMMERSION = 2; + */ + IMMERSION = 2, +} + +/** + * Describes the enum device_set.v1.RackCoolingType. + */ +export const RackCoolingTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_device_set_v1_device_set, 2); + +/** + * Status of a device in a specific rack slot position + * + * @generated from enum device_set.v1.SlotDeviceStatus + */ +export enum SlotDeviceStatus { + /** + * @generated from enum value: SLOT_DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_EMPTY = 1; + */ + EMPTY = 1, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_HEALTHY = 2; + */ + HEALTHY = 2, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_NEEDS_ATTENTION = 3; + */ + NEEDS_ATTENTION = 3, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_OFFLINE = 4; + */ + OFFLINE = 4, + + /** + * @generated from enum value: SLOT_DEVICE_STATUS_SLEEPING = 5; + */ + SLEEPING = 5, +} + +/** + * Describes the enum device_set.v1.SlotDeviceStatus. + */ +export const SlotDeviceStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_device_set_v1_device_set, 3); + +/** + * Service for managing device sets (groups and racks) + * Device sets allow grouping devices for filtering and bulk operations + * + * @generated from service device_set.v1.DeviceSetService + */ +export const DeviceSetService: GenService<{ + /** + * Creates a new device set + * + * @generated from rpc device_set.v1.DeviceSetService.CreateDeviceSet + */ + createDeviceSet: { + methodKind: "unary"; + input: typeof CreateDeviceSetRequestSchema; + output: typeof CreateDeviceSetResponseSchema; + }; + /** + * Gets a device set by ID + * + * @generated from rpc device_set.v1.DeviceSetService.GetDeviceSet + */ + getDeviceSet: { + methodKind: "unary"; + input: typeof GetDeviceSetRequestSchema; + output: typeof GetDeviceSetResponseSchema; + }; + /** + * Updates a device set's label or description + * + * @generated from rpc device_set.v1.DeviceSetService.UpdateDeviceSet + */ + updateDeviceSet: { + methodKind: "unary"; + input: typeof UpdateDeviceSetRequestSchema; + output: typeof UpdateDeviceSetResponseSchema; + }; + /** + * Deletes a device set (soft delete) + * + * @generated from rpc device_set.v1.DeviceSetService.DeleteDeviceSet + */ + deleteDeviceSet: { + methodKind: "unary"; + input: typeof DeleteDeviceSetRequestSchema; + output: typeof DeleteDeviceSetResponseSchema; + }; + /** + * Lists all device sets for the organization + * + * @generated from rpc device_set.v1.DeviceSetService.ListDeviceSets + */ + listDeviceSets: { + methodKind: "unary"; + input: typeof ListDeviceSetsRequestSchema; + output: typeof ListDeviceSetsResponseSchema; + }; + /** + * Adds devices to a device set + * + * @generated from rpc device_set.v1.DeviceSetService.AddDevicesToDeviceSet + */ + addDevicesToDeviceSet: { + methodKind: "unary"; + input: typeof AddDevicesToDeviceSetRequestSchema; + output: typeof AddDevicesToDeviceSetResponseSchema; + }; + /** + * Removes devices from a device set + * + * @generated from rpc device_set.v1.DeviceSetService.RemoveDevicesFromDeviceSet + */ + removeDevicesFromDeviceSet: { + methodKind: "unary"; + input: typeof RemoveDevicesFromDeviceSetRequestSchema; + output: typeof RemoveDevicesFromDeviceSetResponseSchema; + }; + /** + * Lists members of a device set + * + * @generated from rpc device_set.v1.DeviceSetService.ListDeviceSetMembers + */ + listDeviceSetMembers: { + methodKind: "unary"; + input: typeof ListDeviceSetMembersRequestSchema; + output: typeof ListDeviceSetMembersResponseSchema; + }; + /** + * Gets device sets that a device belongs to + * + * @generated from rpc device_set.v1.DeviceSetService.GetDeviceDeviceSets + */ + getDeviceDeviceSets: { + methodKind: "unary"; + input: typeof GetDeviceDeviceSetsRequestSchema; + output: typeof GetDeviceDeviceSetsResponseSchema; + }; + /** + * Sets a device's slot position within a rack + * + * @generated from rpc device_set.v1.DeviceSetService.SetRackSlotPosition + */ + setRackSlotPosition: { + methodKind: "unary"; + input: typeof SetRackSlotPositionRequestSchema; + output: typeof SetRackSlotPositionResponseSchema; + }; + /** + * Clears a device's slot position within a rack + * + * @generated from rpc device_set.v1.DeviceSetService.ClearRackSlotPosition + */ + clearRackSlotPosition: { + methodKind: "unary"; + input: typeof ClearRackSlotPositionRequestSchema; + output: typeof ClearRackSlotPositionResponseSchema; + }; + /** + * Lists all occupied slot positions in a rack + * + * @generated from rpc device_set.v1.DeviceSetService.GetRackSlots + */ + getRackSlots: { + methodKind: "unary"; + input: typeof GetRackSlotsRequestSchema; + output: typeof GetRackSlotsResponseSchema; + }; + /** + * Returns aggregated telemetry stats for a list of device sets + * + * @generated from rpc device_set.v1.DeviceSetService.GetDeviceSetStats + */ + getDeviceSetStats: { + methodKind: "unary"; + input: typeof GetDeviceSetStatsRequestSchema; + output: typeof GetDeviceSetStatsResponseSchema; + }; + /** + * Returns all distinct rack zones for the organization + * + * @generated from rpc device_set.v1.DeviceSetService.ListRackZones + */ + listRackZones: { + methodKind: "unary"; + input: typeof ListRackZonesRequestSchema; + output: typeof ListRackZonesResponseSchema; + }; + /** + * Returns all distinct rack types (row/column combinations) for the organization + * + * @generated from rpc device_set.v1.DeviceSetService.ListRackTypes + */ + listRackTypes: { + methodKind: "unary"; + input: typeof ListRackTypesRequestSchema; + output: typeof ListRackTypesResponseSchema; + }; + /** + * Atomically creates or updates a rack with its membership and slot assignments. + * All operations (metadata, membership, slot positions) are applied in a single transaction. + * + * @generated from rpc device_set.v1.DeviceSetService.SaveRack + */ + saveRack: { + methodKind: "unary"; + input: typeof SaveRackRequestSchema; + output: typeof SaveRackResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_device_set_v1_device_set, 0); diff --git a/client/src/protoFleet/api/generated/errors/v1/errors_pb.ts b/client/src/protoFleet/api/generated/errors/v1/errors_pb.ts new file mode 100644 index 000000000..4a3652ab9 --- /dev/null +++ b/client/src/protoFleet/api/generated/errors/v1/errors_pb.ts @@ -0,0 +1,1244 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file errors/v1/errors.proto (package errors.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file errors/v1/errors.proto. + */ +export const file_errors_v1_errors: GenFile = + /*@__PURE__*/ + fileDesc( + "ChZlcnJvcnMvdjEvZXJyb3JzLnByb3RvEgllcnJvcnMudjEi2wQKDEVycm9yTWVzc2FnZRIQCghlcnJvcl9pZBgBIAEoCRIuCg9jYW5vbmljYWxfZXJyb3IYAiABKA4yFS5lcnJvcnMudjEuTWluZXJFcnJvchIPCgdzdW1tYXJ5GAMgASgJEhUKDWNhdXNlX3N1bW1hcnkYBCABKAkSGgoScmVjb21tZW5kZWRfYWN0aW9uGAUgASgJEiUKCHNldmVyaXR5GAYgASgOMhMuZXJyb3JzLnYxLlNldmVyaXR5EjEKDWZpcnN0X3NlZW5fYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDGxhc3Rfc2Vlbl9hdBgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLQoJY2xvc2VkX2F0GAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBJIChF2ZW5kb3JfYXR0cmlidXRlcxgKIAMoCzItLmVycm9ycy52MS5FcnJvck1lc3NhZ2UuVmVuZG9yQXR0cmlidXRlc0VudHJ5EhkKEWRldmljZV9pZGVudGlmaWVyGAsgASgJEhkKDGNvbXBvbmVudF9pZBgMIAEoCUgAiAEBEg4KBmltcGFjdBgNIAEoCRIwCg5jb21wb25lbnRfdHlwZRgOIAEoDjIYLmVycm9ycy52MS5Db21wb25lbnRUeXBlGjcKFVZlbmRvckF0dHJpYnV0ZXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQg8KDV9jb21wb25lbnRfaWQiPAoHU3VtbWFyeRINCgV0aXRsZRgBIAEoCRIPCgdkZXRhaWxzGAIgASgJEhEKCWNvbmRlbnNlZBgDIAEoCSKxAgoLRGV2aWNlRXJyb3ISGQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAkSEwoLZGV2aWNlX3R5cGUYAiABKAkSIQoGc3RhdHVzGAMgASgOMhEuZXJyb3JzLnYxLlN0YXR1cxIjCgdzdW1tYXJ5GAQgASgLMhIuZXJyb3JzLnYxLlN1bW1hcnkSJwoGZXJyb3JzGAUgAygLMhcuZXJyb3JzLnYxLkVycm9yTWVzc2FnZRJIChJjb3VudHNfYnlfc2V2ZXJpdHkYBiADKAsyLC5lcnJvcnMudjEuRGV2aWNlRXJyb3IuQ291bnRzQnlTZXZlcml0eUVudHJ5GjcKFUNvdW50c0J5U2V2ZXJpdHlFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAU6AjgBIuoCCg5Db21wb25lbnRFcnJvchIUCgxjb21wb25lbnRfaWQYASABKAkSMAoOY29tcG9uZW50X3R5cGUYAiABKA4yGC5lcnJvcnMudjEuQ29tcG9uZW50VHlwZRIZChFkZXZpY2VfaWRlbnRpZmllchgDIAEoCRIhCgZzdGF0dXMYBCABKA4yES5lcnJvcnMudjEuU3RhdHVzEiMKB3N1bW1hcnkYBSABKAsyEi5lcnJvcnMudjEuU3VtbWFyeRInCgZlcnJvcnMYBiADKAsyFy5lcnJvcnMudjEuRXJyb3JNZXNzYWdlEksKEmNvdW50c19ieV9zZXZlcml0eRgHIAMoCzIvLmVycm9ycy52MS5Db21wb25lbnRFcnJvci5Db3VudHNCeVNldmVyaXR5RW50cnkaNwoVQ291bnRzQnlTZXZlcml0eUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoBToCOAEi5AEKDFNpbXBsZUZpbHRlchIaChJkZXZpY2VfaWRlbnRpZmllcnMYASADKAkSFAoMZGV2aWNlX3R5cGVzGAIgAygJEhUKDWNvbXBvbmVudF9pZHMYAyADKAkSMQoPY29tcG9uZW50X3R5cGVzGAQgAygOMhguZXJyb3JzLnYxLkNvbXBvbmVudFR5cGUSLwoQY2Fub25pY2FsX2Vycm9ycxgFIAMoDjIVLmVycm9ycy52MS5NaW5lckVycm9yEicKCnNldmVyaXRpZXMYBiADKA4yEy5lcnJvcnMudjEuU2V2ZXJpdHki0wEKBkZpbHRlchInCgZzaW1wbGUYASABKAsyFy5lcnJvcnMudjEuU2ltcGxlRmlsdGVyEiwKDHNpbXBsZV9sb2dpYxgCIAEoDjIWLmVycm9ycy52MS5HbG9iYWxMb2dpYxItCgl0aW1lX2Zyb20YAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEisKB3RpbWVfdG8YBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDmluY2x1ZGVfY2xvc2VkGAUgASgIIqIBCgxRdWVyeVJlcXVlc3QSKgoLcmVzdWx0X3ZpZXcYASABKA4yFS5lcnJvcnMudjEuUmVzdWx0VmlldxIhCgZmaWx0ZXIYAiABKAsyES5lcnJvcnMudjEuRmlsdGVyEh0KCXBhZ2Vfc2l6ZRgDIAEoBUIKukgHGgUY6AcoARISCgpwYWdlX3Rva2VuGAQgASgJEhAKCG9yZGVyX2J5GAUgASgJIsoBCg1RdWVyeVJlc3BvbnNlEiMKBmVycm9ycxgBIAEoCzIRLmVycm9ycy52MS5FcnJvcnNIABIwCgpjb21wb25lbnRzGAIgASgLMhouZXJyb3JzLnYxLkNvbXBvbmVudEVycm9yc0gAEioKB2RldmljZXMYAyABKAsyFy5lcnJvcnMudjEuRGV2aWNlRXJyb3JzSAASFwoPbmV4dF9wYWdlX3Rva2VuGAogASgJEhMKC3RvdGFsX2NvdW50GAsgASgDQggKBnJlc3VsdCIwCgZFcnJvcnMSJgoFaXRlbXMYASADKAsyFy5lcnJvcnMudjEuRXJyb3JNZXNzYWdlIjsKD0NvbXBvbmVudEVycm9ycxIoCgVpdGVtcxgBIAMoCzIZLmVycm9ycy52MS5Db21wb25lbnRFcnJvciI1CgxEZXZpY2VFcnJvcnMSJQoFaXRlbXMYASADKAsyFi5lcnJvcnMudjEuRGV2aWNlRXJyb3IiLAoPR2V0RXJyb3JSZXF1ZXN0EhkKCGVycm9yX2lkGAEgASgJQge6SARyAhABIjoKEEdldEVycm9yUmVzcG9uc2USJgoFZXJyb3IYASABKAsyFy5lcnJvcnMudjEuRXJyb3JNZXNzYWdlIhgKFkxpc3RNaW5lckVycm9yc1JlcXVlc3QiQwoXTGlzdE1pbmVyRXJyb3JzUmVzcG9uc2USKAoFaXRlbXMYASADKAsyGS5lcnJvcnMudjEuTWluZXJFcnJvckluZm8iuwEKDk1pbmVyRXJyb3JJbmZvEiMKBGNvZGUYASABKA4yFS5lcnJvcnMudjEuTWluZXJFcnJvchIMCgRuYW1lGAIgASgJEhcKD2RlZmF1bHRfc3VtbWFyeRgDIAEoCRItChBkZWZhdWx0X3NldmVyaXR5GAQgASgOMhMuZXJyb3JzLnYxLlNldmVyaXR5EhYKDmRlZmF1bHRfYWN0aW9uGAUgASgJEhYKDmRlZmF1bHRfaW1wYWN0GAYgASgJIjEKDFdhdGNoUmVxdWVzdBIhCgZmaWx0ZXIYASABKAsyES5lcnJvcnMudjEuRmlsdGVyIpsCCg1XYXRjaFJlc3BvbnNlEiMKBmVycm9ycxgBIAEoCzIRLmVycm9ycy52MS5FcnJvcnNIABIwCgpjb21wb25lbnRzGAIgASgLMhouZXJyb3JzLnYxLkNvbXBvbmVudEVycm9yc0gAEioKB2RldmljZXMYAyABKAsyFy5lcnJvcnMudjEuRGV2aWNlRXJyb3JzSAASKwoEa2luZBgEIAEoDjIdLmVycm9ycy52MS5XYXRjaFJlc3BvbnNlLktpbmQiUAoES2luZBIUChBLSU5EX1VOU1BFQ0lGSUVEEAASDwoLS0lORF9PUEVORUQQARIQCgxLSU5EX1VQREFURUQQAhIPCgtLSU5EX0NMT1NFRBADQggKBnJlc3VsdCrjDwoKTWluZXJFcnJvchIbChdNSU5FUl9FUlJPUl9VTlNQRUNJRklFRBAAEiAKG01JTkVSX0VSUk9SX1BTVV9OT1RfUFJFU0VOVBDoBxIjCh5NSU5FUl9FUlJPUl9QU1VfTU9ERUxfTUlTTUFUQ0gQ6QcSJwoiTUlORVJfRVJST1JfUFNVX0NPTU1VTklDQVRJT05fTE9TVBDqBxIiCh1NSU5FUl9FUlJPUl9QU1VfRkFVTFRfR0VORVJJQxDrBxImCiFNSU5FUl9FUlJPUl9QU1VfSU5QVVRfVk9MVEFHRV9MT1cQ7AcSJwoiTUlORVJfRVJST1JfUFNVX0lOUFVUX1ZPTFRBR0VfSElHSBDtBxIpCiRNSU5FUl9FUlJPUl9QU1VfT1VUUFVUX1ZPTFRBR0VfRkFVTFQQ7gcSJwoiTUlORVJfRVJST1JfUFNVX09VVFBVVF9PVkVSQ1VSUkVOVBDvBxIfChpNSU5FUl9FUlJPUl9QU1VfRkFOX0ZBSUxFRBDwBxIlCiBNSU5FUl9FUlJPUl9QU1VfT1ZFUl9URU1QRVJBVFVSRRDxBxIqCiVNSU5FUl9FUlJPUl9QU1VfSU5QVVRfUEhBU0VfSU1CQUxBTkNFEPIHEiYKIU1JTkVSX0VSUk9SX1BTVV9VTkRFUl9URU1QRVJBVFVSRRDzBxIbChZNSU5FUl9FUlJPUl9GQU5fRkFJTEVEENAPEiUKIE1JTkVSX0VSUk9SX0ZBTl9UQUNIX1NJR05BTF9MT1NUENEPEiQKH01JTkVSX0VSUk9SX0ZBTl9TUEVFRF9ERVZJQVRJT04Q0g8SJwoiTUlORVJfRVJST1JfSU5MRVRfT1ZFUl9URU1QRVJBVFVSRRDaDxIoCiNNSU5FUl9FUlJPUl9ERVZJQ0VfT1ZFUl9URU1QRVJBVFVSRRDbDxIpCiRNSU5FUl9FUlJPUl9ERVZJQ0VfVU5ERVJfVEVNUEVSQVRVUkUQ3A8SJgohTUlORVJfRVJST1JfSEFTSEJPQVJEX05PVF9QUkVTRU5UELgXEisKJk1JTkVSX0VSUk9SX0hBU0hCT0FSRF9PVkVSX1RFTVBFUkFUVVJFELkXEigKI01JTkVSX0VSUk9SX0hBU0hCT0FSRF9NSVNTSU5HX0NISVBTELoXEi4KKU1JTkVSX0VSUk9SX0FTSUNfQ0hBSU5fQ09NTVVOSUNBVElPTl9MT1NUELsXEigKI01JTkVSX0VSUk9SX0FTSUNfQ0xPQ0tfUExMX1VOTE9DS0VEELwXEikKJE1JTkVSX0VSUk9SX0FTSUNfQ1JDX0VSUk9SX0VYQ0VTU0lWRRC9FxIwCitNSU5FUl9FUlJPUl9IQVNIQk9BUkRfQVNJQ19PVkVSX1RFTVBFUkFUVVJFEL4XEjEKLE1JTkVSX0VSUk9SX0hBU0hCT0FSRF9BU0lDX1VOREVSX1RFTVBFUkFUVVJFEL8XEioKJU1JTkVSX0VSUk9SX0JPQVJEX1BPV0VSX1BHT09EX01JU1NJTkcQrBsSLQooTUlORVJfRVJST1JfQk9BUkRfUE9XRVJfT1ZFUkNVUlJFTlRfVFJJUBCtGxIrCiZNSU5FUl9FUlJPUl9CT0FSRF9QT1dFUl9SQUlMX1VOREVSVk9MVBCuGxIqCiVNSU5FUl9FUlJPUl9CT0FSRF9QT1dFUl9SQUlMX09WRVJWT0xUEK8bEisKJk1JTkVSX0VSUk9SX0JPQVJEX1BPV0VSX1NIT1JUX0RFVEVDVEVEELAbEioKJU1JTkVSX0VSUk9SX1RFTVBfU0VOU09SX09QRU5fT1JfU0hPUlQQoB8SIgodTUlORVJfRVJST1JfVEVNUF9TRU5TT1JfRkFVTFQQoR8SJQogTUlORVJfRVJST1JfVk9MVEFHRV9TRU5TT1JfRkFVTFQQoh8SJQogTUlORVJfRVJST1JfQ1VSUkVOVF9TRU5TT1JfRkFVTFQQox8SJAofTUlORVJfRVJST1JfRUVQUk9NX0NSQ19NSVNNQVRDSBCIJxIkCh9NSU5FUl9FUlJPUl9FRVBST01fUkVBRF9GQUlMVVJFEIknEicKIk1JTkVSX0VSUk9SX0ZJUk1XQVJFX0lNQUdFX0lOVkFMSUQQiicSKAojTUlORVJfRVJST1JfRklSTVdBUkVfQ09ORklHX0lOVkFMSUQQiycSMQosTUlORVJfRVJST1JfQ09OVFJPTF9CT0FSRF9DT01NVU5JQ0FUSU9OX0xPU1QQ8C4SJgohTUlORVJfRVJST1JfQ09OVFJPTF9CT0FSRF9GQUlMVVJFEPEuEioKJU1JTkVSX0VSUk9SX0RFVklDRV9JTlRFUk5BTF9CVVNfRkFVTFQQ8i4SKgolTUlORVJfRVJST1JfREVWSUNFX0NPTU1VTklDQVRJT05fTE9TVBDzLhIiCh1NSU5FUl9FUlJPUl9JT19NT0RVTEVfRkFJTFVSRRD6LhImCiFNSU5FUl9FUlJPUl9IQVNIUkFURV9CRUxPV19UQVJHRVQQwD4SKAojTUlORVJfRVJST1JfSEFTSEJPQVJEX1dBUk5fQ1JDX0hJR0gQwT4SIwoeTUlORVJfRVJST1JfVEhFUk1BTF9NQVJHSU5fTE9XEMI+EiYKIU1JTkVSX0VSUk9SX1ZFTkRPUl9FUlJPUl9VTk1BUFBFRBCoRip2CghTZXZlcml0eRIYChRTRVZFUklUWV9VTlNQRUNJRklFRBAAEhUKEVNFVkVSSVRZX0NSSVRJQ0FMEAESEgoOU0VWRVJJVFlfTUFKT1IQAhISCg5TRVZFUklUWV9NSU5PUhADEhEKDVNFVkVSSVRZX0lORk8QBCpVCgZTdGF0dXMSFgoSU1RBVFVTX1VOU1BFQ0lGSUVEEAASDQoJU1RBVFVTX09LEAESEgoOU1RBVFVTX1dBUk5JTkcQAhIQCgxTVEFUVVNfRVJST1IQAyrZAQoNQ29tcG9uZW50VHlwZRIeChpDT01QT05FTlRfVFlQRV9VTlNQRUNJRklFRBAAEhYKEkNPTVBPTkVOVF9UWVBFX1BTVRABEh0KGUNPTVBPTkVOVF9UWVBFX0hBU0hfQk9BUkQQAhIWChJDT01QT05FTlRfVFlQRV9GQU4QAxIgChxDT01QT05FTlRfVFlQRV9DT05UUk9MX0JPQVJEEAQSGQoVQ09NUE9ORU5UX1RZUEVfRUVQUk9NEAUSHAoYQ09NUE9ORU5UX1RZUEVfSU9fTU9EVUxFEAYqcwoKUmVzdWx0VmlldxIbChdSRVNVTFRfVklFV19VTlNQRUNJRklFRBAAEhUKEVJFU1VMVF9WSUVXX0VSUk9SEAESGQoVUkVTVUxUX1ZJRVdfQ09NUE9ORU5UEAISFgoSUkVTVUxUX1ZJRVdfREVWSUNFEAMqVgoLR2xvYmFsTG9naWMSHAoYR0xPQkFMX0xPR0lDX1VOU1BFQ0lGSUVEEAASFAoQR0xPQkFMX0xPR0lDX0FORBABEhMKD0dMT0JBTF9MT0dJQ19PUhACMqwCChFFcnJvclF1ZXJ5U2VydmljZRI6CgVRdWVyeRIXLmVycm9ycy52MS5RdWVyeVJlcXVlc3QaGC5lcnJvcnMudjEuUXVlcnlSZXNwb25zZRJDCghHZXRFcnJvchIaLmVycm9ycy52MS5HZXRFcnJvclJlcXVlc3QaGy5lcnJvcnMudjEuR2V0RXJyb3JSZXNwb25zZRJYCg9MaXN0TWluZXJFcnJvcnMSIS5lcnJvcnMudjEuTGlzdE1pbmVyRXJyb3JzUmVxdWVzdBoiLmVycm9ycy52MS5MaXN0TWluZXJFcnJvcnNSZXNwb25zZRI8CgVXYXRjaBIXLmVycm9ycy52MS5XYXRjaFJlcXVlc3QaGC5lcnJvcnMudjEuV2F0Y2hSZXNwb25zZTABQqgBCg1jb20uZXJyb3JzLnYxQgtFcnJvcnNQcm90b1ABWkVnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9lcnJvcnMvdjE7ZXJyb3JzdjGiAgNFWFiqAglFcnJvcnMuVjHKAglFcnJvcnNcVjHiAhVFcnJvcnNcVjFcR1BCTWV0YWRhdGHqAgpFcnJvcnM6OlYxYgZwcm90bzM", + [file_google_protobuf_timestamp, file_buf_validate_validate], + ); + +/** + * A single error instance + * + * @generated from message errors.v1.ErrorMessage + */ +export type ErrorMessage = Message<"errors.v1.ErrorMessage"> & { + /** + * ULID (globally unique, time-sortable) + * + * @generated from field: string error_id = 1; + */ + errorId: string; + + /** + * REQUIRED + * + * @generated from field: errors.v1.MinerError canonical_error = 2; + */ + canonicalError: MinerError; + + /** + * Human-readable short description (typically vendor-provided) + * + * @generated from field: string summary = 3; + */ + summary: string; + + /** + * Human-readable short reason + * + * @generated from field: string cause_summary = 4; + */ + causeSummary: string; + + /** + * Next best action + * + * @generated from field: string recommended_action = 5; + */ + recommendedAction: string; + + /** + * Technical severity classification + * + * @generated from field: errors.v1.Severity severity = 6; + */ + severity: Severity; + + /** + * @generated from field: google.protobuf.Timestamp first_seen_at = 7; + */ + firstSeenAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp last_seen_at = 8; + */ + lastSeenAt?: Timestamp; + + /** + * Optional: when error was resolved + * + * @generated from field: google.protobuf.Timestamp closed_at = 9; + */ + closedAt?: Timestamp; + + /** + * e.g., firmware, code, serials + * + * @generated from field: map vendor_attributes = 10; + */ + vendorAttributes: { [key: string]: string }; + + /** + * device_identifier of the Device this error belongs to + * + * @generated from field: string device_identifier = 11; + */ + deviceIdentifier: string; + + /** + * Optional component identifier + * + * @generated from field: optional string component_id = 12; + */ + componentId?: string; + + /** + * Human-readable business impact + * + * @generated from field: string impact = 13; + */ + impact: string; + + /** + * Type of hardware component (hashboard, fan, PSU, etc.) + * + * @generated from field: errors.v1.ComponentType component_type = 14; + */ + componentType: ComponentType; +}; + +/** + * Describes the message errors.v1.ErrorMessage. + * Use `create(ErrorMessageSchema)` to create a new message. + */ +export const ErrorMessageSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 0); + +/** + * Rich summary structure for aggregated views + * + * @generated from message errors.v1.Summary + */ +export type Summary = Message<"errors.v1.Summary"> & { + /** + * One-liner: "3 critical PSU errors" + * + * @generated from field: string title = 1; + */ + title: string; + + /** + * Full multi-line description with context + * + * @generated from field: string details = 2; + */ + details: string; + + /** + * Ultra-short for UI chips: "3 PSU" + * + * @generated from field: string condensed = 3; + */ + condensed: string; +}; + +/** + * Describes the message errors.v1.Summary. + * Use `create(SummarySchema)` to create a new message. + */ +export const SummarySchema: GenMessage

= /*@__PURE__*/ messageDesc(file_errors_v1_errors, 1); + +/** + * Errors grouped by device + * + * @generated from message errors.v1.DeviceError + */ +export type DeviceError = Message<"errors.v1.DeviceError"> & { + /** + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Model name + * + * @generated from field: string device_type = 2; + */ + deviceType: string; + + /** + * @generated from field: errors.v1.Status status = 3; + */ + status: Status; + + /** + * @generated from field: errors.v1.Summary summary = 4; + */ + summary?: Summary; + + /** + * @generated from field: repeated errors.v1.ErrorMessage errors = 5; + */ + errors: ErrorMessage[]; + + /** + * Error counts per severity level + * + * @generated from field: map counts_by_severity = 6; + */ + countsBySeverity: { [key: string]: number }; +}; + +/** + * Describes the message errors.v1.DeviceError. + * Use `create(DeviceErrorSchema)` to create a new message. + */ +export const DeviceErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 2); + +/** + * Errors grouped by component + * + * @generated from message errors.v1.ComponentError + */ +export type ComponentError = Message<"errors.v1.ComponentError"> & { + /** + * @generated from field: string component_id = 1; + */ + componentId: string; + + /** + * @generated from field: errors.v1.ComponentType component_type = 2; + */ + componentType: ComponentType; + + /** + * @generated from field: string device_identifier = 3; + */ + deviceIdentifier: string; + + /** + * @generated from field: errors.v1.Status status = 4; + */ + status: Status; + + /** + * @generated from field: errors.v1.Summary summary = 5; + */ + summary?: Summary; + + /** + * @generated from field: repeated errors.v1.ErrorMessage errors = 6; + */ + errors: ErrorMessage[]; + + /** + * Error counts per severity level + * + * @generated from field: map counts_by_severity = 7; + */ + countsBySeverity: { [key: string]: number }; +}; + +/** + * Describes the message errors.v1.ComponentError. + * Use `create(ComponentErrorSchema)` to create a new message. + */ +export const ComponentErrorSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 3); + +/** + * Simple filter with field lists + * + * @generated from message errors.v1.SimpleFilter + */ +export type SimpleFilter = Message<"errors.v1.SimpleFilter"> & { + /** + * Device identifiers (e.g., "proto-12345") + * + * @generated from field: repeated string device_identifiers = 1; + */ + deviceIdentifiers: string[]; + + /** + * Model names (e.g., "R2", "S19") + * + * @generated from field: repeated string device_types = 2; + */ + deviceTypes: string[]; + + /** + * @generated from field: repeated string component_ids = 3; + */ + componentIds: string[]; + + /** + * @generated from field: repeated errors.v1.ComponentType component_types = 4; + */ + componentTypes: ComponentType[]; + + /** + * @generated from field: repeated errors.v1.MinerError canonical_errors = 5; + */ + canonicalErrors: MinerError[]; + + /** + * Filter by technical severity + * + * @generated from field: repeated errors.v1.Severity severities = 6; + */ + severities: Severity[]; +}; + +/** + * Describes the message errors.v1.SimpleFilter. + * Use `create(SimpleFilterSchema)` to create a new message. + */ +export const SimpleFilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 4); + +/** + * Complete filter with time range and logic options + * + * @generated from message errors.v1.Filter + */ +export type Filter = Message<"errors.v1.Filter"> & { + /** + * @generated from field: errors.v1.SimpleFilter simple = 1; + */ + simple?: SimpleFilter; + + /** + * @generated from field: errors.v1.GlobalLogic simple_logic = 2; + */ + simpleLogic: GlobalLogic; + + /** + * Optional + * + * @generated from field: google.protobuf.Timestamp time_from = 3; + */ + timeFrom?: Timestamp; + + /** + * Optional + * + * @generated from field: google.protobuf.Timestamp time_to = 4; + */ + timeTo?: Timestamp; + + /** + * Include closed/expired errors + * + * @generated from field: bool include_closed = 5; + */ + includeClosed: boolean; +}; + +/** + * Describes the message errors.v1.Filter. + * Use `create(FilterSchema)` to create a new message. + */ +export const FilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 5); + +/** + * Query request with pagination + * + * @generated from message errors.v1.QueryRequest + */ +export type QueryRequest = Message<"errors.v1.QueryRequest"> & { + /** + * @generated from field: errors.v1.ResultView result_view = 1; + */ + resultView: ResultView; + + /** + * @generated from field: errors.v1.Filter filter = 2; + */ + filter?: Filter; + + /** + * @generated from field: int32 page_size = 3; + */ + pageSize: number; + + /** + * @generated from field: string page_token = 4; + */ + pageToken: string; + + /** + * Reserved for future use. Currently sorting is fixed: severity DESC, last_seen_at DESC, error_id DESC. + * This field is ignored by the server. + * + * @generated from field: string order_by = 5; + */ + orderBy: string; +}; + +/** + * Describes the message errors.v1.QueryRequest. + * Use `create(QueryRequestSchema)` to create a new message. + */ +export const QueryRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 6); + +/** + * Query response with polymorphic result + * + * @generated from message errors.v1.QueryResponse + */ +export type QueryResponse = Message<"errors.v1.QueryResponse"> & { + /** + * @generated from oneof errors.v1.QueryResponse.result + */ + result: + | { + /** + * @generated from field: errors.v1.Errors errors = 1; + */ + value: Errors; + case: "errors"; + } + | { + /** + * @generated from field: errors.v1.ComponentErrors components = 2; + */ + value: ComponentErrors; + case: "components"; + } + | { + /** + * @generated from field: errors.v1.DeviceErrors devices = 3; + */ + value: DeviceErrors; + case: "devices"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from field: string next_page_token = 10; + */ + nextPageToken: string; + + /** + * Total matching items + * + * @generated from field: int64 total_count = 11; + */ + totalCount: bigint; +}; + +/** + * Describes the message errors.v1.QueryResponse. + * Use `create(QueryResponseSchema)` to create a new message. + */ +export const QueryResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 7); + +/** + * Page of error messages + * + * @generated from message errors.v1.Errors + */ +export type Errors = Message<"errors.v1.Errors"> & { + /** + * @generated from field: repeated errors.v1.ErrorMessage items = 1; + */ + items: ErrorMessage[]; +}; + +/** + * Describes the message errors.v1.Errors. + * Use `create(ErrorsSchema)` to create a new message. + */ +export const ErrorsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 8); + +/** + * Page of component errors + * + * @generated from message errors.v1.ComponentErrors + */ +export type ComponentErrors = Message<"errors.v1.ComponentErrors"> & { + /** + * @generated from field: repeated errors.v1.ComponentError items = 1; + */ + items: ComponentError[]; +}; + +/** + * Describes the message errors.v1.ComponentErrors. + * Use `create(ComponentErrorsSchema)` to create a new message. + */ +export const ComponentErrorsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 9); + +/** + * Page of device errors + * + * @generated from message errors.v1.DeviceErrors + */ +export type DeviceErrors = Message<"errors.v1.DeviceErrors"> & { + /** + * @generated from field: repeated errors.v1.DeviceError items = 1; + */ + items: DeviceError[]; +}; + +/** + * Describes the message errors.v1.DeviceErrors. + * Use `create(DeviceErrorsSchema)` to create a new message. + */ +export const DeviceErrorsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 10); + +/** + * Get single error request + * + * @generated from message errors.v1.GetErrorRequest + */ +export type GetErrorRequest = Message<"errors.v1.GetErrorRequest"> & { + /** + * @generated from field: string error_id = 1; + */ + errorId: string; +}; + +/** + * Describes the message errors.v1.GetErrorRequest. + * Use `create(GetErrorRequestSchema)` to create a new message. + */ +export const GetErrorRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 11); + +/** + * Get single error response + * + * @generated from message errors.v1.GetErrorResponse + */ +export type GetErrorResponse = Message<"errors.v1.GetErrorResponse"> & { + /** + * @generated from field: errors.v1.ErrorMessage error = 1; + */ + error?: ErrorMessage; +}; + +/** + * Describes the message errors.v1.GetErrorResponse. + * Use `create(GetErrorResponseSchema)` to create a new message. + */ +export const GetErrorResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_errors_v1_errors, 12); + +/** + * List canonical errors request (no parameters) + * + * @generated from message errors.v1.ListMinerErrorsRequest + */ +export type ListMinerErrorsRequest = Message<"errors.v1.ListMinerErrorsRequest"> & {}; + +/** + * Describes the message errors.v1.ListMinerErrorsRequest. + * Use `create(ListMinerErrorsRequestSchema)` to create a new message. + */ +export const ListMinerErrorsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_errors_v1_errors, 13); + +/** + * List canonical errors response + * + * @generated from message errors.v1.ListMinerErrorsResponse + */ +export type ListMinerErrorsResponse = Message<"errors.v1.ListMinerErrorsResponse"> & { + /** + * @generated from field: repeated errors.v1.MinerErrorInfo items = 1; + */ + items: MinerErrorInfo[]; +}; + +/** + * Describes the message errors.v1.ListMinerErrorsResponse. + * Use `create(ListMinerErrorsResponseSchema)` to create a new message. + */ +export const ListMinerErrorsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_errors_v1_errors, 14); + +/** + * Metadata about a canonical error code + * + * @generated from message errors.v1.MinerErrorInfo + */ +export type MinerErrorInfo = Message<"errors.v1.MinerErrorInfo"> & { + /** + * @generated from field: errors.v1.MinerError code = 1; + */ + code: MinerError; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string default_summary = 3; + */ + defaultSummary: string; + + /** + * @generated from field: errors.v1.Severity default_severity = 4; + */ + defaultSeverity: Severity; + + /** + * @generated from field: string default_action = 5; + */ + defaultAction: string; + + /** + * Human-readable business impact description + * + * @generated from field: string default_impact = 6; + */ + defaultImpact: string; +}; + +/** + * Describes the message errors.v1.MinerErrorInfo. + * Use `create(MinerErrorInfoSchema)` to create a new message. + */ +export const MinerErrorInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 15); + +/** + * Watch request for streaming updates + * + * @generated from message errors.v1.WatchRequest + */ +export type WatchRequest = Message<"errors.v1.WatchRequest"> & { + /** + * @generated from field: errors.v1.Filter filter = 1; + */ + filter?: Filter; +}; + +/** + * Describes the message errors.v1.WatchRequest. + * Use `create(WatchRequestSchema)` to create a new message. + */ +export const WatchRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 16); + +/** + * Watch response for streaming updates + * + * @generated from message errors.v1.WatchResponse + */ +export type WatchResponse = Message<"errors.v1.WatchResponse"> & { + /** + * @generated from oneof errors.v1.WatchResponse.result + */ + result: + | { + /** + * @generated from field: errors.v1.Errors errors = 1; + */ + value: Errors; + case: "errors"; + } + | { + /** + * @generated from field: errors.v1.ComponentErrors components = 2; + */ + value: ComponentErrors; + case: "components"; + } + | { + /** + * @generated from field: errors.v1.DeviceErrors devices = 3; + */ + value: DeviceErrors; + case: "devices"; + } + | { case: undefined; value?: undefined }; + + /** + * @generated from field: errors.v1.WatchResponse.Kind kind = 4; + */ + kind: WatchResponse_Kind; +}; + +/** + * Describes the message errors.v1.WatchResponse. + * Use `create(WatchResponseSchema)` to create a new message. + */ +export const WatchResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_errors_v1_errors, 17); + +/** + * @generated from enum errors.v1.WatchResponse.Kind + */ +export enum WatchResponse_Kind { + /** + * @generated from enum value: KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: KIND_OPENED = 1; + */ + OPENED = 1, + + /** + * @generated from enum value: KIND_UPDATED = 2; + */ + UPDATED = 2, + + /** + * @generated from enum value: KIND_CLOSED = 3; + */ + CLOSED = 3, +} + +/** + * Describes the enum errors.v1.WatchResponse.Kind. + */ +export const WatchResponse_KindSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_errors_v1_errors, 17, 0); + +/** + * Canonical, miner-agnostic error codes + * - 1xx PSU & facility power at PSU terminals + * - 2xx Thermal & fans + * - 3xx Board/ASIC chain & hash performance + * - 35x Board-level power rails & protection (distinct from PSU) + * - 4xx Sensors (electrical faults, not just out-of-range) + * - 5xx Non-volatile storage / firmware + * - 6xx Control-plane & on-board comms (hardware-side) + * - 8xx Performance advisories (non-fatal) + * - 9xx Catch-alls / vendor-unknown + * + * @generated from enum errors.v1.MinerError + */ +export enum MinerError { + /** + * @generated from enum value: MINER_ERROR_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * 1000–1049: PSU (unit-level faults) + * + * @generated from enum value: MINER_ERROR_PSU_NOT_PRESENT = 1000; + */ + PSU_NOT_PRESENT = 1000, + + /** + * @generated from enum value: MINER_ERROR_PSU_MODEL_MISMATCH = 1001; + */ + PSU_MODEL_MISMATCH = 1001, + + /** + * @generated from enum value: MINER_ERROR_PSU_COMMUNICATION_LOST = 1002; + */ + PSU_COMMUNICATION_LOST = 1002, + + /** + * @generated from enum value: MINER_ERROR_PSU_FAULT_GENERIC = 1003; + */ + PSU_FAULT_GENERIC = 1003, + + /** + * @generated from enum value: MINER_ERROR_PSU_INPUT_VOLTAGE_LOW = 1004; + */ + PSU_INPUT_VOLTAGE_LOW = 1004, + + /** + * @generated from enum value: MINER_ERROR_PSU_INPUT_VOLTAGE_HIGH = 1005; + */ + PSU_INPUT_VOLTAGE_HIGH = 1005, + + /** + * @generated from enum value: MINER_ERROR_PSU_OUTPUT_VOLTAGE_FAULT = 1006; + */ + PSU_OUTPUT_VOLTAGE_FAULT = 1006, + + /** + * @generated from enum value: MINER_ERROR_PSU_OUTPUT_OVERCURRENT = 1007; + */ + PSU_OUTPUT_OVERCURRENT = 1007, + + /** + * @generated from enum value: MINER_ERROR_PSU_FAN_FAILED = 1008; + */ + PSU_FAN_FAILED = 1008, + + /** + * @generated from enum value: MINER_ERROR_PSU_OVER_TEMPERATURE = 1009; + */ + PSU_OVER_TEMPERATURE = 1009, + + /** + * @generated from enum value: MINER_ERROR_PSU_INPUT_PHASE_IMBALANCE = 1010; + */ + PSU_INPUT_PHASE_IMBALANCE = 1010, + + /** + * @generated from enum value: MINER_ERROR_PSU_UNDER_TEMPERATURE = 1011; + */ + PSU_UNDER_TEMPERATURE = 1011, + + /** + * 2000–2029: Thermal & fans (device-level) + * + * @generated from enum value: MINER_ERROR_FAN_FAILED = 2000; + */ + FAN_FAILED = 2000, + + /** + * @generated from enum value: MINER_ERROR_FAN_TACH_SIGNAL_LOST = 2001; + */ + FAN_TACH_SIGNAL_LOST = 2001, + + /** + * @generated from enum value: MINER_ERROR_FAN_SPEED_DEVIATION = 2002; + */ + FAN_SPEED_DEVIATION = 2002, + + /** + * @generated from enum value: MINER_ERROR_INLET_OVER_TEMPERATURE = 2010; + */ + INLET_OVER_TEMPERATURE = 2010, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_OVER_TEMPERATURE = 2011; + */ + DEVICE_OVER_TEMPERATURE = 2011, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_UNDER_TEMPERATURE = 2012; + */ + DEVICE_UNDER_TEMPERATURE = 2012, + + /** + * 3000–3049: Hashboard / ASIC chain & core digital + * + * @generated from enum value: MINER_ERROR_HASHBOARD_NOT_PRESENT = 3000; + */ + HASHBOARD_NOT_PRESENT = 3000, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_OVER_TEMPERATURE = 3001; + */ + HASHBOARD_OVER_TEMPERATURE = 3001, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_MISSING_CHIPS = 3002; + */ + HASHBOARD_MISSING_CHIPS = 3002, + + /** + * @generated from enum value: MINER_ERROR_ASIC_CHAIN_COMMUNICATION_LOST = 3003; + */ + ASIC_CHAIN_COMMUNICATION_LOST = 3003, + + /** + * @generated from enum value: MINER_ERROR_ASIC_CLOCK_PLL_UNLOCKED = 3004; + */ + ASIC_CLOCK_PLL_UNLOCKED = 3004, + + /** + * @generated from enum value: MINER_ERROR_ASIC_CRC_ERROR_EXCESSIVE = 3005; + */ + ASIC_CRC_ERROR_EXCESSIVE = 3005, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_ASIC_OVER_TEMPERATURE = 3006; + */ + HASHBOARD_ASIC_OVER_TEMPERATURE = 3006, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_ASIC_UNDER_TEMPERATURE = 3007; + */ + HASHBOARD_ASIC_UNDER_TEMPERATURE = 3007, + + /** + * 3500–3699: Board-level power rails & protection (distinct from PSU) + * + * @generated from enum value: MINER_ERROR_BOARD_POWER_PGOOD_MISSING = 3500; + */ + BOARD_POWER_PGOOD_MISSING = 3500, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_OVERCURRENT_TRIP = 3501; + */ + BOARD_POWER_OVERCURRENT_TRIP = 3501, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_RAIL_UNDERVOLT = 3502; + */ + BOARD_POWER_RAIL_UNDERVOLT = 3502, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_RAIL_OVERVOLT = 3503; + */ + BOARD_POWER_RAIL_OVERVOLT = 3503, + + /** + * @generated from enum value: MINER_ERROR_BOARD_POWER_SHORT_DETECTED = 3504; + */ + BOARD_POWER_SHORT_DETECTED = 3504, + + /** + * 4000–4029: Sensors (electrical faults) + * + * @generated from enum value: MINER_ERROR_TEMP_SENSOR_OPEN_OR_SHORT = 4000; + */ + TEMP_SENSOR_OPEN_OR_SHORT = 4000, + + /** + * @generated from enum value: MINER_ERROR_TEMP_SENSOR_FAULT = 4001; + */ + TEMP_SENSOR_FAULT = 4001, + + /** + * @generated from enum value: MINER_ERROR_VOLTAGE_SENSOR_FAULT = 4002; + */ + VOLTAGE_SENSOR_FAULT = 4002, + + /** + * @generated from enum value: MINER_ERROR_CURRENT_SENSOR_FAULT = 4003; + */ + CURRENT_SENSOR_FAULT = 4003, + + /** + * 5000–5499: Non-volatile storage / firmware (hardware-adjacent) + * + * @generated from enum value: MINER_ERROR_EEPROM_CRC_MISMATCH = 5000; + */ + EEPROM_CRC_MISMATCH = 5000, + + /** + * @generated from enum value: MINER_ERROR_EEPROM_READ_FAILURE = 5001; + */ + EEPROM_READ_FAILURE = 5001, + + /** + * @generated from enum value: MINER_ERROR_FIRMWARE_IMAGE_INVALID = 5002; + */ + FIRMWARE_IMAGE_INVALID = 5002, + + /** + * @generated from enum value: MINER_ERROR_FIRMWARE_CONFIG_INVALID = 5003; + */ + FIRMWARE_CONFIG_INVALID = 5003, + + /** + * 6000–6499: Control-plane & on-board comms (hardware-side) + * + * @generated from enum value: MINER_ERROR_CONTROL_BOARD_COMMUNICATION_LOST = 6000; + */ + CONTROL_BOARD_COMMUNICATION_LOST = 6000, + + /** + * @generated from enum value: MINER_ERROR_CONTROL_BOARD_FAILURE = 6001; + */ + CONTROL_BOARD_FAILURE = 6001, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_INTERNAL_BUS_FAULT = 6002; + */ + DEVICE_INTERNAL_BUS_FAULT = 6002, + + /** + * @generated from enum value: MINER_ERROR_DEVICE_COMMUNICATION_LOST = 6003; + */ + DEVICE_COMMUNICATION_LOST = 6003, + + /** + * @generated from enum value: MINER_ERROR_IO_MODULE_FAILURE = 6010; + */ + IO_MODULE_FAILURE = 6010, + + /** + * 8000–8029: Performance advisories (non-fatal canonical warnings) + * + * @generated from enum value: MINER_ERROR_HASHRATE_BELOW_TARGET = 8000; + */ + HASHRATE_BELOW_TARGET = 8000, + + /** + * @generated from enum value: MINER_ERROR_HASHBOARD_WARN_CRC_HIGH = 8001; + */ + HASHBOARD_WARN_CRC_HIGH = 8001, + + /** + * @generated from enum value: MINER_ERROR_THERMAL_MARGIN_LOW = 8002; + */ + THERMAL_MARGIN_LOW = 8002, + + /** + * 9000–9029: Catch-alls + * + * @generated from enum value: MINER_ERROR_VENDOR_ERROR_UNMAPPED = 9000; + */ + VENDOR_ERROR_UNMAPPED = 9000, +} + +/** + * Describes the enum errors.v1.MinerError. + */ +export const MinerErrorSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 0); + +/** + * Severity classification for errors + * + * @generated from enum errors.v1.Severity + */ +export enum Severity { + /** + * @generated from enum value: SEVERITY_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Miner stops hashing or unsafe + * + * @generated from enum value: SEVERITY_CRITICAL = 1; + */ + CRITICAL = 1, + + /** + * Degraded hashing / imminent trip + * + * @generated from enum value: SEVERITY_MAJOR = 2; + */ + MAJOR = 2, + + /** + * Recoverable, limited effect + * + * @generated from enum value: SEVERITY_MINOR = 3; + */ + MINOR = 3, + + /** + * Informational / advisory + * + * @generated from enum value: SEVERITY_INFO = 4; + */ + INFO = 4, +} + +/** + * Describes the enum errors.v1.Severity. + */ +export const SeveritySchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 1); + +/** + * Aggregated status based on error severity waterfall + * ERROR if any CRITICAL, WARNING if any MAJOR/MINOR/INFO, else OK + * + * @generated from enum errors.v1.Status + */ +export enum Status { + /** + * @generated from enum value: STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * No open errors + * + * @generated from enum value: STATUS_OK = 1; + */ + OK = 1, + + /** + * Major, minor, or info severity errors present + * + * @generated from enum value: STATUS_WARNING = 2; + */ + WARNING = 2, + + /** + * Critical severity errors present + * + * @generated from enum value: STATUS_ERROR = 3; + */ + ERROR = 3, +} + +/** + * Describes the enum errors.v1.Status. + */ +export const StatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 2); + +/** + * Type of component that can have errors + * + * @generated from enum errors.v1.ComponentType + */ +export enum ComponentType { + /** + * @generated from enum value: COMPONENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMPONENT_TYPE_PSU = 1; + */ + PSU = 1, + + /** + * @generated from enum value: COMPONENT_TYPE_HASH_BOARD = 2; + */ + HASH_BOARD = 2, + + /** + * @generated from enum value: COMPONENT_TYPE_FAN = 3; + */ + FAN = 3, + + /** + * @generated from enum value: COMPONENT_TYPE_CONTROL_BOARD = 4; + */ + CONTROL_BOARD = 4, + + /** + * @generated from enum value: COMPONENT_TYPE_EEPROM = 5; + */ + EEPROM = 5, + + /** + * @generated from enum value: COMPONENT_TYPE_IO_MODULE = 6; + */ + IO_MODULE = 6, +} + +/** + * Describes the enum errors.v1.ComponentType. + */ +export const ComponentTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 3); + +/** + * Result view determines how errors are aggregated in the response + * + * @generated from enum errors.v1.ResultView + */ +export enum ResultView { + /** + * @generated from enum value: RESULT_VIEW_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Flat list of individual errors + * + * @generated from enum value: RESULT_VIEW_ERROR = 1; + */ + ERROR = 1, + + /** + * Grouped by component + * + * @generated from enum value: RESULT_VIEW_COMPONENT = 2; + */ + COMPONENT = 2, + + /** + * Grouped by device + * + * @generated from enum value: RESULT_VIEW_DEVICE = 3; + */ + DEVICE = 3, +} + +/** + * Describes the enum errors.v1.ResultView. + */ +export const ResultViewSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 4); + +/** + * Global logic for combining filter criteria. + * TODO: Currently only AND logic is implemented. OR support planned. + * + * @generated from enum errors.v1.GlobalLogic + */ +export enum GlobalLogic { + /** + * @generated from enum value: GLOBAL_LOGIC_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * All criteria must match (default, currently only mode) + * + * @generated from enum value: GLOBAL_LOGIC_AND = 1; + */ + AND = 1, + + /** + * Any criterion can match (not yet implemented) + * + * @generated from enum value: GLOBAL_LOGIC_OR = 2; + */ + OR = 2, +} + +/** + * Describes the enum errors.v1.GlobalLogic. + */ +export const GlobalLogicSchema: GenEnum = /*@__PURE__*/ enumDesc(file_errors_v1_errors, 5); + +/** + * ErrorQueryService provides querying capabilities for miner errors + * + * @generated from service errors.v1.ErrorQueryService + */ +export const ErrorQueryService: GenService<{ + /** + * Query errors with filtering, pagination, and result view options + * + * @generated from rpc errors.v1.ErrorQueryService.Query + */ + query: { + methodKind: "unary"; + input: typeof QueryRequestSchema; + output: typeof QueryResponseSchema; + }; + /** + * Get a single error by ID + * + * @generated from rpc errors.v1.ErrorQueryService.GetError + */ + getError: { + methodKind: "unary"; + input: typeof GetErrorRequestSchema; + output: typeof GetErrorResponseSchema; + }; + /** + * List all canonical error definitions with metadata + * + * @generated from rpc errors.v1.ErrorQueryService.ListMinerErrors + */ + listMinerErrors: { + methodKind: "unary"; + input: typeof ListMinerErrorsRequestSchema; + output: typeof ListMinerErrorsResponseSchema; + }; + /** + * Watch for real-time error updates (streaming) + * + * @generated from rpc errors.v1.ErrorQueryService.Watch + */ + watch: { + methodKind: "server_streaming"; + input: typeof WatchRequestSchema; + output: typeof WatchResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_errors_v1_errors, 0); diff --git a/client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts b/client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts new file mode 100644 index 000000000..80aa2ec5e --- /dev/null +++ b/client/src/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb.ts @@ -0,0 +1,1620 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file fleetmanagement/v1/fleetmanagement.proto (package fleetmanagement.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Measurement } from "../../common/v1/measurement_pb"; +import { file_common_v1_measurement } from "../../common/v1/measurement_pb"; +import type { CoolingMode } from "../../common/v1/cooling_pb"; +import { file_common_v1_cooling } from "../../common/v1/cooling_pb"; +import type { MinerStateCounts, TemperatureStatus } from "../../telemetry/v1/telemetry_pb"; +import { file_telemetry_v1_telemetry } from "../../telemetry/v1/telemetry_pb"; +import type { MinerCapabilities } from "../../capabilities/v1/capabilities_pb"; +import { file_capabilities_v1_capabilities } from "../../capabilities/v1/capabilities_pb"; +import type { ComponentType } from "../../errors/v1/errors_pb"; +import { file_errors_v1_errors } from "../../errors/v1/errors_pb"; +import type { DeviceIdentifierList } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { SortConfig } from "../../common/v1/sort_pb"; +import { file_common_v1_sort } from "../../common/v1/sort_pb"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file fleetmanagement/v1/fleetmanagement.proto. + */ +export const file_fleetmanagement_v1_fleetmanagement: GenFile = + /*@__PURE__*/ + fileDesc( + "CihmbGVldG1hbmFnZW1lbnQvdjEvZmxlZXRtYW5hZ2VtZW50LnByb3RvEhJmbGVldG1hbmFnZW1lbnQudjEiigYKEk1pbmVyU3RhdGVTbmFwc2hvdBIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRIMCgRuYW1lGAIgASgJEhMKC21hY19hZGRyZXNzGAMgASgJEhUKDXNlcmlhbF9udW1iZXIYBCABKAkSKwoLcG93ZXJfdXNhZ2UYBSADKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQSKwoLdGVtcGVyYXR1cmUYBiADKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQSKAoIaGFzaHJhdGUYByADKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQSKgoKZWZmaWNpZW5jeRgIIAMoCzIWLmNvbW1vbi52MS5NZWFzdXJlbWVudBItCgl0aW1lc3RhbXAYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCmlwX2FkZHJlc3MYCiABKAkSCwoDdXJsGAsgASgJEjcKDWRldmljZV9zdGF0dXMYDCABKA4yIC5mbGVldG1hbmFnZW1lbnQudjEuRGV2aWNlU3RhdHVzEjkKDnBhaXJpbmdfc3RhdHVzGA0gASgOMiEuZmxlZXRtYW5hZ2VtZW50LnYxLlBhaXJpbmdTdGF0dXMSDQoFbW9kZWwYDiABKAkSFAoMbWFudWZhY3R1cmVyGA8gASgJEjgKDGNhcGFiaWxpdGllcxgRIAEoCzIiLmNhcGFiaWxpdGllcy52MS5NaW5lckNhcGFiaWxpdGllcxI7ChJ0ZW1wZXJhdHVyZV9zdGF0dXMYEiABKA4yHy50ZWxlbWV0cnkudjEuVGVtcGVyYXR1cmVTdGF0dXMSGAoQZmlybXdhcmVfdmVyc2lvbhgTIAEoCRIUCgxncm91cF9sYWJlbHMYFCADKAkSEgoKcmFja19sYWJlbBgVIAEoCRITCgtkcml2ZXJfbmFtZRgWIAEoCRITCgt3b3JrZXJfbmFtZRgXIAEoCRIVCg1yYWNrX3Bvc2l0aW9uGBggASgJSgQIEBARUgR0eXBlIqkBCh5MaXN0TWluZXJTdGF0ZVNuYXBzaG90c1JlcXVlc3QSHQoJcGFnZV9zaXplGAEgASgFQgq6SAcaBRjoBygAEg4KBmN1cnNvchgCIAEoCRIzCgZmaWx0ZXIYAyABKAsyIy5mbGVldG1hbmFnZW1lbnQudjEuTWluZXJMaXN0RmlsdGVyEiMKBHNvcnQYBCADKAsyFS5jb21tb24udjEuU29ydENvbmZpZyL1AQoPTWluZXJMaXN0RmlsdGVyEjcKDWRldmljZV9zdGF0dXMYAyADKA4yIC5mbGVldG1hbmFnZW1lbnQudjEuRGV2aWNlU3RhdHVzEjcKFWVycm9yX2NvbXBvbmVudF90eXBlcxgEIAMoDjIYLmVycm9ycy52MS5Db21wb25lbnRUeXBlEg4KBm1vZGVscxgFIAMoCRI7ChBwYWlyaW5nX3N0YXR1c2VzGAYgAygOMiEuZmxlZXRtYW5hZ2VtZW50LnYxLlBhaXJpbmdTdGF0dXMSEQoJZ3JvdXBfaWRzGAcgAygDEhAKCHJhY2tfaWRzGAggAygDIssBCh9MaXN0TWluZXJTdGF0ZVNuYXBzaG90c1Jlc3BvbnNlEjYKBm1pbmVycxgBIAMoCzImLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lclN0YXRlU25hcHNob3QSDgoGY3Vyc29yGAIgASgJEhQKDHRvdGFsX21pbmVycxgDIAEoBRI6ChJ0b3RhbF9zdGF0ZV9jb3VudHMYBCABKAsyHi50ZWxlbWV0cnkudjEuTWluZXJTdGF0ZUNvdW50cxIOCgZtb2RlbHMYBSADKAkikgEKGUV4cG9ydE1pbmVyTGlzdENzdlJlcXVlc3QSMwoGZmlsdGVyGAEgASgLMiMuZmxlZXRtYW5hZ2VtZW50LnYxLk1pbmVyTGlzdEZpbHRlchJAChB0ZW1wZXJhdHVyZV91bml0GAIgASgOMiYuZmxlZXRtYW5hZ2VtZW50LnYxLkNzdlRlbXBlcmF0dXJlVW5pdCIuChpFeHBvcnRNaW5lckxpc3RDc3ZSZXNwb25zZRIQCghjc3ZfZGF0YRgBIAEoDCIcChpHZXRNaW5lclN0YXRlQ291bnRzUmVxdWVzdCJpChtHZXRNaW5lclN0YXRlQ291bnRzUmVzcG9uc2USFAoMdG90YWxfbWluZXJzGAEgASgFEjQKDHN0YXRlX2NvdW50cxgCIAEoCzIeLnRlbGVtZXRyeS52MS5NaW5lclN0YXRlQ291bnRzIkMKHkdldE1pbmVyUG9vbEFzc2lnbm1lbnRzUmVxdWVzdBIhChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIGukgDyAEBIlEKDlBvb2xBc3NpZ25tZW50EhQKB3Bvb2xfaWQYASABKANIAIgBARILCgN1cmwYAiABKAkSEAoIdXNlcm5hbWUYAyABKAlCCgoIX3Bvb2xfaWQiVAofR2V0TWluZXJQb29sQXNzaWdubWVudHNSZXNwb25zZRIxCgVwb29scxgBIAMoCzIiLmZsZWV0bWFuYWdlbWVudC52MS5Qb29sQXNzaWdubWVudCJFCg9NaW5lck1vZGVsR3JvdXASDQoFbW9kZWwYASABKAkSFAoMbWFudWZhY3R1cmVyGAIgASgJEg0KBWNvdW50GAMgASgFIlEKGkdldE1pbmVyTW9kZWxHcm91cHNSZXF1ZXN0EjMKBmZpbHRlchgBIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lckxpc3RGaWx0ZXIiUgobR2V0TWluZXJNb2RlbEdyb3Vwc1Jlc3BvbnNlEjMKBmdyb3VwcxgBIAMoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lck1vZGVsR3JvdXAiPwoaR2V0TWluZXJDb29saW5nTW9kZVJlcXVlc3QSIQoRZGV2aWNlX2lkZW50aWZpZXIYASABKAlCBrpIA8gBASJLChtHZXRNaW5lckNvb2xpbmdNb2RlUmVzcG9uc2USLAoMY29vbGluZ19tb2RlGAEgASgOMhYuY29tbW9uLnYxLkNvb2xpbmdNb2RlIpoBCg5EZXZpY2VTZWxlY3RvchI6CgthbGxfZGV2aWNlcxgBIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lckxpc3RGaWx0ZXJIABI6Cg9pbmNsdWRlX2RldmljZXMYAiABKAsyHy5jb21tb24udjEuRGV2aWNlSWRlbnRpZmllckxpc3RIAEIQCg5zZWxlY3Rpb25fdHlwZSJSChNEZWxldGVNaW5lcnNSZXF1ZXN0EjsKD2RldmljZV9zZWxlY3RvchgBIAEoCzIiLmZsZWV0bWFuYWdlbWVudC52MS5EZXZpY2VTZWxlY3RvciItChREZWxldGVNaW5lcnNSZXNwb25zZRIVCg1kZWxldGVkX2NvdW50GAEgASgFIsEBChNSZW5hbWVNaW5lcnNSZXF1ZXN0EkMKD2RldmljZV9zZWxlY3RvchgBIAEoCzIiLmZsZWV0bWFuYWdlbWVudC52MS5EZXZpY2VTZWxlY3RvckIGukgDyAEBEkAKC25hbWVfY29uZmlnGAIgASgLMiMuZmxlZXRtYW5hZ2VtZW50LnYxLk1pbmVyTmFtZUNvbmZpZ0IGukgDyAEBEiMKBHNvcnQYAyADKAsyFS5jb21tb24udjEuU29ydENvbmZpZyJcChRSZW5hbWVNaW5lcnNSZXNwb25zZRIVCg1yZW5hbWVkX2NvdW50GAEgASgFEhcKD3VuY2hhbmdlZF9jb3VudBgCIAEoBRIUCgxmYWlsZWRfY291bnQYAyABKAUihgIKGFVwZGF0ZVdvcmtlck5hbWVzUmVxdWVzdBJDCg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyIi5mbGVldG1hbmFnZW1lbnQudjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBARJACgtuYW1lX2NvbmZpZxgCIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5NaW5lck5hbWVDb25maWdCBrpIA8gBARIjCgRzb3J0GAMgAygLMhUuY29tbW9uLnYxLlNvcnRDb25maWcSHgoNdXNlcl91c2VybmFtZRgEIAEoCUIHukgEcgIQARIeCg11c2VyX3Bhc3N3b3JkGAUgASgJQge6SARyAhABInsKGVVwZGF0ZVdvcmtlck5hbWVzUmVzcG9uc2USFQoNdXBkYXRlZF9jb3VudBgBIAEoBRIXCg91bmNoYW5nZWRfY291bnQYAiABKAUSFAoMZmFpbGVkX2NvdW50GAMgASgFEhgKEGJhdGNoX2lkZW50aWZpZXIYBCABKAkidgoPTWluZXJOYW1lQ29uZmlnEj4KCnByb3BlcnRpZXMYASADKAsyIC5mbGVldG1hbmFnZW1lbnQudjEuTmFtZVByb3BlcnR5Qgi6SAWSAQIIARIjCglzZXBhcmF0b3IYAiABKAlCELpIDXILUgEtUgFfUgEuUgAi0QIKDE5hbWVQcm9wZXJ0eRJKChJzdHJpbmdfYW5kX2NvdW50ZXIYASABKAsyLC5mbGVldG1hbmFnZW1lbnQudjEuU3RyaW5nQW5kQ291bnRlclByb3BlcnR5SAASNgoHY291bnRlchgCIAEoCzIjLmZsZWV0bWFuYWdlbWVudC52MS5Db3VudGVyUHJvcGVydHlIABI6CgxzdHJpbmdfdmFsdWUYAyABKAsyIi5mbGVldG1hbmFnZW1lbnQudjEuU3RyaW5nUHJvcGVydHlIABI9CgtmaXhlZF92YWx1ZRgEIAEoCzImLmZsZWV0bWFuYWdlbWVudC52MS5GaXhlZFZhbHVlUHJvcGVydHlIABI6CglxdWFsaWZpZXIYBSABKAsyJS5mbGVldG1hbmFnZW1lbnQudjEuUXVhbGlmaWVyUHJvcGVydHlIAEIGCgRraW5kInwKGFN0cmluZ0FuZENvdW50ZXJQcm9wZXJ0eRIOCgZwcmVmaXgYASABKAkSDgoGc3VmZml4GAIgASgJEh4KDWNvdW50ZXJfc3RhcnQYAyABKAVCB7pIBBoCKAASIAoNY291bnRlcl9zY2FsZRgEIAEoBUIJukgGGgQYBigBIlMKD0NvdW50ZXJQcm9wZXJ0eRIeCg1jb3VudGVyX3N0YXJ0GAEgASgFQge6SAQaAigAEiAKDWNvdW50ZXJfc2NhbGUYAiABKAVCCbpIBhoEGAYoASIoCg5TdHJpbmdQcm9wZXJ0eRIWCgV2YWx1ZRgBIAEoCUIHukgEcgIQASLyAgoSRml4ZWRWYWx1ZVByb3BlcnR5EjoKBHR5cGUYASABKA4yIi5mbGVldG1hbmFnZW1lbnQudjEuRml4ZWRWYWx1ZVR5cGVCCLpIBYIBAhABEicKD2NoYXJhY3Rlcl9jb3VudBgCIAEoBUIJukgGGgQYBigBSACIAQESRAoHc2VjdGlvbhgDIAEoDjIkLmZsZWV0bWFuYWdlbWVudC52MS5DaGFyYWN0ZXJTZWN0aW9uQgi6SAWCAQIQAUgBiAEBOpABukiMARqJAQolc2VjdGlvbl9yZXF1aXJlZF93aXRoX2NoYXJhY3Rlcl9jb3VudBIvc2VjdGlvbiBpcyByZXF1aXJlZCB3aGVuIGNoYXJhY3Rlcl9jb3VudCBpcyBzZXQaLyFoYXModGhpcy5jaGFyYWN0ZXJfY291bnQpIHx8IGhhcyh0aGlzLnNlY3Rpb24pQhIKEF9jaGFyYWN0ZXJfY291bnRCCgoIX3NlY3Rpb24ibgoRUXVhbGlmaWVyUHJvcGVydHkSOQoEdHlwZRgBIAEoDjIhLmZsZWV0bWFuYWdlbWVudC52MS5RdWFsaWZpZXJUeXBlQgi6SAWCAQIQARIOCgZwcmVmaXgYAiABKAkSDgoGc3VmZml4GAMgASgJKpkBCh9GbGVldE1hbmFnZW1lbnRTZXJ2aWNlRXJyb3JDb2RlEjMKL0ZMRUVUX01BTkFHRU1FTlRfU0VSVklDRV9FUlJPUl9DT0RFX1VOU1BFQ0lGSUVEEAASQQo9RkxFRVRfTUFOQUdFTUVOVF9TRVJWSUNFX0VSUk9SX0NPREVfSU5WQUxJRF9QQUdJTkFUSU9OX0NVUlNPUhABKpoCCgxEZXZpY2VTdGF0dXMSHQoZREVWSUNFX1NUQVRVU19VTlNQRUNJRklFRBAAEhgKFERFVklDRV9TVEFUVVNfT05MSU5FEAESGQoVREVWSUNFX1NUQVRVU19PRkZMSU5FEAISHQoZREVWSUNFX1NUQVRVU19NQUlOVEVOQU5DRRADEhcKE0RFVklDRV9TVEFUVVNfRVJST1IQBBIaChZERVZJQ0VfU1RBVFVTX0lOQUNUSVZFEAUSIwofREVWSUNFX1NUQVRVU19ORUVEU19NSU5JTkdfUE9PTBAGEhoKFkRFVklDRV9TVEFUVVNfVVBEQVRJTkcQBxIhCh1ERVZJQ0VfU1RBVFVTX1JFQk9PVF9SRVFVSVJFRBAIKsgBCg1QYWlyaW5nU3RhdHVzEh4KGlBBSVJJTkdfU1RBVFVTX1VOU1BFQ0lGSUVEEAASGQoVUEFJUklOR19TVEFUVVNfUEFJUkVEEAESGwoXUEFJUklOR19TVEFUVVNfVU5QQUlSRUQQAhIoCiRQQUlSSU5HX1NUQVRVU19BVVRIRU5USUNBVElPTl9ORUVERUQQAxIaChZQQUlSSU5HX1NUQVRVU19QRU5ESU5HEAQSGQoVUEFJUklOR19TVEFUVVNfRkFJTEVEEAUqgQEKEkNzdlRlbXBlcmF0dXJlVW5pdBIkCiBDU1ZfVEVNUEVSQVRVUkVfVU5JVF9VTlNQRUNJRklFRBAAEiAKHENTVl9URU1QRVJBVFVSRV9VTklUX0NFTFNJVVMQARIjCh9DU1ZfVEVNUEVSQVRVUkVfVU5JVF9GQUhSRU5IRUlUEAIqmQIKDkZpeGVkVmFsdWVUeXBlEiAKHEZJWEVEX1ZBTFVFX1RZUEVfVU5TUEVDSUZJRUQQABIgChxGSVhFRF9WQUxVRV9UWVBFX01BQ19BRERSRVNTEAESIgoeRklYRURfVkFMVUVfVFlQRV9TRVJJQUxfTlVNQkVSEAISIAocRklYRURfVkFMVUVfVFlQRV9XT1JLRVJfTkFNRRADEhoKFkZJWEVEX1ZBTFVFX1RZUEVfTU9ERUwQBBIhCh1GSVhFRF9WQUxVRV9UWVBFX01BTlVGQUNUVVJFUhAFEh0KGUZJWEVEX1ZBTFVFX1RZUEVfTE9DQVRJT04QBhIfChtGSVhFRF9WQUxVRV9UWVBFX01JTkVSX05BTUUQBypuChBDaGFyYWN0ZXJTZWN0aW9uEiEKHUNIQVJBQ1RFUl9TRUNUSU9OX1VOU1BFQ0lGSUVEEAASGwoXQ0hBUkFDVEVSX1NFQ1RJT05fRklSU1QQARIaChZDSEFSQUNURVJfU0VDVElPTl9MQVNUEAIqhwEKDVF1YWxpZmllclR5cGUSHgoaUVVBTElGSUVSX1RZUEVfVU5TUEVDSUZJRUQQABIbChdRVUFMSUZJRVJfVFlQRV9CVUlMRElORxABEhcKE1FVQUxJRklFUl9UWVBFX1JBQ0sQAhIgChxRVUFMSUZJRVJfVFlQRV9SQUNLX1BPU0lUSU9OEAMyuQgKFkZsZWV0TWFuYWdlbWVudFNlcnZpY2USggEKF0xpc3RNaW5lclN0YXRlU25hcHNob3RzEjIuZmxlZXRtYW5hZ2VtZW50LnYxLkxpc3RNaW5lclN0YXRlU25hcHNob3RzUmVxdWVzdBozLmZsZWV0bWFuYWdlbWVudC52MS5MaXN0TWluZXJTdGF0ZVNuYXBzaG90c1Jlc3BvbnNlEnUKEkV4cG9ydE1pbmVyTGlzdENzdhItLmZsZWV0bWFuYWdlbWVudC52MS5FeHBvcnRNaW5lckxpc3RDc3ZSZXF1ZXN0Gi4uZmxlZXRtYW5hZ2VtZW50LnYxLkV4cG9ydE1pbmVyTGlzdENzdlJlc3BvbnNlMAESdgoTR2V0TWluZXJTdGF0ZUNvdW50cxIuLmZsZWV0bWFuYWdlbWVudC52MS5HZXRNaW5lclN0YXRlQ291bnRzUmVxdWVzdBovLmZsZWV0bWFuYWdlbWVudC52MS5HZXRNaW5lclN0YXRlQ291bnRzUmVzcG9uc2USggEKF0dldE1pbmVyUG9vbEFzc2lnbm1lbnRzEjIuZmxlZXRtYW5hZ2VtZW50LnYxLkdldE1pbmVyUG9vbEFzc2lnbm1lbnRzUmVxdWVzdBozLmZsZWV0bWFuYWdlbWVudC52MS5HZXRNaW5lclBvb2xBc3NpZ25tZW50c1Jlc3BvbnNlEnYKE0dldE1pbmVyQ29vbGluZ01vZGUSLi5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJDb29saW5nTW9kZVJlcXVlc3QaLy5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJDb29saW5nTW9kZVJlc3BvbnNlEmEKDERlbGV0ZU1pbmVycxInLmZsZWV0bWFuYWdlbWVudC52MS5EZWxldGVNaW5lcnNSZXF1ZXN0GiguZmxlZXRtYW5hZ2VtZW50LnYxLkRlbGV0ZU1pbmVyc1Jlc3BvbnNlEnYKE0dldE1pbmVyTW9kZWxHcm91cHMSLi5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJNb2RlbEdyb3Vwc1JlcXVlc3QaLy5mbGVldG1hbmFnZW1lbnQudjEuR2V0TWluZXJNb2RlbEdyb3Vwc1Jlc3BvbnNlEmEKDFJlbmFtZU1pbmVycxInLmZsZWV0bWFuYWdlbWVudC52MS5SZW5hbWVNaW5lcnNSZXF1ZXN0GiguZmxlZXRtYW5hZ2VtZW50LnYxLlJlbmFtZU1pbmVyc1Jlc3BvbnNlEnAKEVVwZGF0ZVdvcmtlck5hbWVzEiwuZmxlZXRtYW5hZ2VtZW50LnYxLlVwZGF0ZVdvcmtlck5hbWVzUmVxdWVzdBotLmZsZWV0bWFuYWdlbWVudC52MS5VcGRhdGVXb3JrZXJOYW1lc1Jlc3BvbnNlQvABChZjb20uZmxlZXRtYW5hZ2VtZW50LnYxQhRGbGVldG1hbmFnZW1lbnRQcm90b1ABWldnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9mbGVldG1hbmFnZW1lbnQvdjE7ZmxlZXRtYW5hZ2VtZW50djGiAgNGWFiqAhJGbGVldG1hbmFnZW1lbnQuVjHKAhJGbGVldG1hbmFnZW1lbnRcVjHiAh5GbGVldG1hbmFnZW1lbnRcVjFcR1BCTWV0YWRhdGHqAhNGbGVldG1hbmFnZW1lbnQ6OlYxYgZwcm90bzM", + [ + file_google_protobuf_timestamp, + file_common_v1_measurement, + file_common_v1_cooling, + file_telemetry_v1_telemetry, + file_capabilities_v1_capabilities, + file_errors_v1_errors, + file_common_v1_device_selector, + file_common_v1_sort, + file_buf_validate_validate, + ], + ); + +/** + * MinerStateSnapshot represents the operational state of a mining device + * including performance metrics and component health status + * Can contain either a single point-in-time snapshot or a time series of measurements + * + * @generated from message fleetmanagement.v1.MinerStateSnapshot + */ +export type MinerStateSnapshot = Message<"fleetmanagement.v1.MinerStateSnapshot"> & { + /** + * Unique identifier for the device within the fleet + * Used as the primary reference for the device in API calls + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * Human-readable name/identifier of the miner + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Physical MAC address of the device in XX:XX:XX:XX:XX:XX format + * + * @generated from field: string mac_address = 3; + */ + macAddress: string; + + /** + * Manufacturer-assigned serial number + * + * @generated from field: string serial_number = 4; + */ + serialNumber: string; + + /** + * Power consumption measurements in kilowatts (kW) + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement power_usage = 5; + */ + powerUsage: Measurement[]; + + /** + * Temperature measurements in degrees Celsius + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement temperature = 6; + */ + temperature: Measurement[]; + + /** + * Hashrate measurements in TH/s (terahash per second) + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement hashrate = 7; + */ + hashrate: Measurement[]; + + /** + * Energy efficiency measurements in joules per terahash (j/TH) + * Lower values indicate better efficiency + * Contains either a single current value or a time series based on request parameters + * + * @generated from field: repeated common.v1.Measurement efficiency = 8; + */ + efficiency: Measurement[]; + + /** + * Timestamp when this snapshot was captured + * For time series data, this represents when the most recent data was collected + * + * @generated from field: google.protobuf.Timestamp timestamp = 9; + */ + timestamp?: Timestamp; + + /** + * @generated from field: string ip_address = 10; + */ + ipAddress: string; + + /** + * The full url of the miner including protocol and port (if running on a port other than 80/443) + * + * @generated from field: string url = 11; + */ + url: string; + + /** + * Current operational status of the device + * + * @generated from field: fleetmanagement.v1.DeviceStatus device_status = 12; + */ + deviceStatus: DeviceStatus; + + /** + * Pairing status of the device + * + * @generated from field: fleetmanagement.v1.PairingStatus pairing_status = 13; + */ + pairingStatus: PairingStatus; + + /** + * Device model name (populated for unpaired devices) + * + * @generated from field: string model = 14; + */ + model: string; + + /** + * Manufacturer name (populated for unpaired devices) + * + * @generated from field: string manufacturer = 15; + */ + manufacturer: string; + + /** + * Device capabilities indicating supported features (populated for unpaired devices) + * + * @generated from field: capabilities.v1.MinerCapabilities capabilities = 17; + */ + capabilities?: MinerCapabilities; + + /** + * Temperature status based on current temperature value + * + * @generated from field: telemetry.v1.TemperatureStatus temperature_status = 18; + */ + temperatureStatus: TemperatureStatus; + + /** + * Firmware version installed on the device + * + * @generated from field: string firmware_version = 19; + */ + firmwareVersion: string; + + /** + * Labels of groups this device belongs to + * Empty if the device is not a member of any group + * + * @generated from field: repeated string group_labels = 20; + */ + groupLabels: string[]; + + /** + * Label of the rack this device belongs to (single value since device can only be in one rack) + * Empty if the device is not assigned to a rack + * + * @generated from field: string rack_label = 21; + */ + rackLabel: string; + + /** + * Driver name identifies which plugin handles this device (e.g., "proto", "antminer"). + * + * @generated from field: string driver_name = 22; + */ + driverName: string; + + /** + * Worker name stored on fleet for pool username composition and rename preview. + * + * @generated from field: string worker_name = 23; + */ + workerName: string; + + /** + * Rack slot position formatted using the rack numbering origin (for example "01"). + * Empty if the device is not assigned to a numbered rack slot. + * + * @generated from field: string rack_position = 24; + */ + rackPosition: string; +}; + +/** + * Describes the message fleetmanagement.v1.MinerStateSnapshot. + * Use `create(MinerStateSnapshotSchema)` to create a new message. + */ +export const MinerStateSnapshotSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 0); + +/** + * Request to list miners with their metadata + * Returns paginated list of miners with identification info and status + * Use GetBatchMinerTelemetry to fetch telemetry for specific miners after initial load + * + * @generated from message fleetmanagement.v1.ListMinerStateSnapshotsRequest + */ +export type ListMinerStateSnapshotsRequest = Message<"fleetmanagement.v1.ListMinerStateSnapshotsRequest"> & { + /** + * Maximum number of miners to return in a single response + * Server may return fewer miners than specified + * If not specified, a default of 50 will be used + * Maximum allowed value is 1000 + * + * @generated from field: int32 page_size = 1; + */ + pageSize: number; + + /** + * A pagination cursor returned by a previous call to this endpoint + * Provide this cursor to retrieve the next set of results for the original query + * Leave empty for first request + * + * @generated from field: string cursor = 2; + */ + cursor: string; + + /** + * Filter criteria for the miners to return + * + * @generated from field: fleetmanagement.v1.MinerListFilter filter = 3; + */ + filter?: MinerListFilter; + + /** + * Sort configuration for results ordering + * If not specified or empty, uses default sort by name ASC (alphabetical) + * Currently only the first element is used; multi-column sorting reserved for future + * + * @generated from field: repeated common.v1.SortConfig sort = 4; + */ + sort: SortConfig[]; +}; + +/** + * Describes the message fleetmanagement.v1.ListMinerStateSnapshotsRequest. + * Use `create(ListMinerStateSnapshotsRequestSchema)` to create a new message. + */ +export const ListMinerStateSnapshotsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 1); + +/** + * Filter criteria for miners + * Multiple filters act as AND condition (all must match) + * + * @generated from message fleetmanagement.v1.MinerListFilter + */ +export type MinerListFilter = Message<"fleetmanagement.v1.MinerListFilter"> & { + /** + * Filter by device status (acts as OR condition) + * Returns miners that match any of the specified device statuses + * + * @generated from field: repeated fleetmanagement.v1.DeviceStatus device_status = 3; + */ + deviceStatus: DeviceStatus[]; + + /** + * Filter by component types that have errors (acts as OR condition) + * Returns miners that have errors for any of the specified component types + * Uses ComponentType from errors.v1 package + * + * @generated from field: repeated errors.v1.ComponentType error_component_types = 4; + */ + errorComponentTypes: ComponentType[]; + + /** + * Filter by device models (acts as OR condition) + * Returns miners that match any of the specified model names (e.g., "S21 XP", "M60") + * + * @generated from field: repeated string models = 5; + */ + models: string[]; + + /** + * Filter by pairing statuses (acts as OR condition) + * Returns miners that match any of the specified pairing statuses + * If empty or only contains PAIRING_STATUS_UNSPECIFIED, returns all devices + * Examples: + * [PAIRED] - Only paired devices + * [UNPAIRED] - Only unpaired devices + * [PAIRED, AUTHENTICATION_NEEDED] - Paired devices + devices needing auth + * [AUTHENTICATION_NEEDED, FAILED] - Devices needing attention + * [] or [UNSPECIFIED] - All devices + * + * @generated from field: repeated fleetmanagement.v1.PairingStatus pairing_statuses = 6; + */ + pairingStatuses: PairingStatus[]; + + /** + * Filter by group IDs. Returns miners that belong to ANY of the specified groups. + * When combined with rack_ids, uses AND logic (device must match both filters). + * + * @generated from field: repeated int64 group_ids = 7; + */ + groupIds: bigint[]; + + /** + * Filter by rack IDs. Returns miners that belong to ANY of the specified racks. + * When combined with group_ids, uses AND logic (device must match both filters). + * + * @generated from field: repeated int64 rack_ids = 8; + */ + rackIds: bigint[]; +}; + +/** + * Describes the message fleetmanagement.v1.MinerListFilter. + * Use `create(MinerListFilterSchema)` to create a new message. + */ +export const MinerListFilterSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 2); + +/** + * Response containing a list of miners with their telemetry + * Used by both ListMinerStateSnapshots (one-time query) and StreamFilteredMinerList (streaming updates) + * + * @generated from message fleetmanagement.v1.ListMinerStateSnapshotsResponse + */ +export type ListMinerStateSnapshotsResponse = Message<"fleetmanagement.v1.ListMinerStateSnapshotsResponse"> & { + /** + * List of miners with their telemetry data + * Contains either snapshot or time series data based on the request + * + * @generated from field: repeated fleetmanagement.v1.MinerStateSnapshot miners = 1; + */ + miners: MinerStateSnapshot[]; + + /** + * The pagination cursor to be used in a subsequent request + * If empty, this is the final page of results + * + * @generated from field: string cursor = 2; + */ + cursor: string; + + /** + * Total number of miners available across all pages + * Useful for UI pagination controls + * + * @generated from field: int32 total_miners = 3; + */ + totalMiners: number; + + /** + * Counts of miners in different states + * This includes counts for all miners, not just the ones in the current page + * Status filters do not affect these counts, because they act as OR condition. + * + * @generated from field: telemetry.v1.MinerStateCounts total_state_counts = 4; + */ + totalStateCounts?: MinerStateCounts; + + /** + * List of all device models that exist in the user's fleet + * Useful for dynamically building model filter options in the UI + * This includes all models across all pages, not just the current page (e.g., "S21 XP", "M60") + * + * @generated from field: repeated string models = 5; + */ + models: string[]; +}; + +/** + * Describes the message fleetmanagement.v1.ListMinerStateSnapshotsResponse. + * Use `create(ListMinerStateSnapshotsResponseSchema)` to create a new message. + */ +export const ListMinerStateSnapshotsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 3); + +/** + * @generated from message fleetmanagement.v1.ExportMinerListCsvRequest + */ +export type ExportMinerListCsvRequest = Message<"fleetmanagement.v1.ExportMinerListCsvRequest"> & { + /** + * Filter criteria for the miners to export. + * + * @generated from field: fleetmanagement.v1.MinerListFilter filter = 1; + */ + filter?: MinerListFilter; + + /** + * Preferred temperature unit for the exported temperature column. + * + * @generated from field: fleetmanagement.v1.CsvTemperatureUnit temperature_unit = 2; + */ + temperatureUnit: CsvTemperatureUnit; +}; + +/** + * Describes the message fleetmanagement.v1.ExportMinerListCsvRequest. + * Use `create(ExportMinerListCsvRequestSchema)` to create a new message. + */ +export const ExportMinerListCsvRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 4); + +/** + * @generated from message fleetmanagement.v1.ExportMinerListCsvResponse + */ +export type ExportMinerListCsvResponse = Message<"fleetmanagement.v1.ExportMinerListCsvResponse"> & { + /** + * Chunk of CSV data. Concatenate all chunks to form the complete file. + * + * @generated from field: bytes csv_data = 1; + */ + csvData: Uint8Array; +}; + +/** + * Describes the message fleetmanagement.v1.ExportMinerListCsvResponse. + * Use `create(ExportMinerListCsvResponseSchema)` to create a new message. + */ +export const ExportMinerListCsvResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 5); + +/** + * Request for getting miner state counts without fetching miner data + * + * Reserved for future use + * + * @generated from message fleetmanagement.v1.GetMinerStateCountsRequest + */ +export type GetMinerStateCountsRequest = Message<"fleetmanagement.v1.GetMinerStateCountsRequest"> & {}; + +/** + * Describes the message fleetmanagement.v1.GetMinerStateCountsRequest. + * Use `create(GetMinerStateCountsRequestSchema)` to create a new message. + */ +export const GetMinerStateCountsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 6); + +/** + * Response containing miner state counts + * + * @generated from message fleetmanagement.v1.GetMinerStateCountsResponse + */ +export type GetMinerStateCountsResponse = Message<"fleetmanagement.v1.GetMinerStateCountsResponse"> & { + /** + * Total number of miners matching the filter + * + * @generated from field: int32 total_miners = 1; + */ + totalMiners: number; + + /** + * Counts of miners in different states + * + * @generated from field: telemetry.v1.MinerStateCounts state_counts = 2; + */ + stateCounts?: MinerStateCounts; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerStateCountsResponse. + * Use `create(GetMinerStateCountsResponseSchema)` to create a new message. + */ +export const GetMinerStateCountsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 7); + +/** + * Request to get the current pool assignments for a specific miner + * + * @generated from message fleetmanagement.v1.GetMinerPoolAssignmentsRequest + */ +export type GetMinerPoolAssignmentsRequest = Message<"fleetmanagement.v1.GetMinerPoolAssignmentsRequest"> & { + /** + * Device identifier of the miner to get pool assignments for + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerPoolAssignmentsRequest. + * Use `create(GetMinerPoolAssignmentsRequestSchema)` to create a new message. + */ +export const GetMinerPoolAssignmentsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 8); + +/** + * A single pool assignment from a miner's configuration + * Groups the pool ID (if matched to a fleet pool) with the raw URL and username + * + * @generated from message fleetmanagement.v1.PoolAssignment + */ +export type PoolAssignment = Message<"fleetmanagement.v1.PoolAssignment"> & { + /** + * Fleet pool ID if this pool matches a fleet pool definition + * Absent if the pool doesn't match any fleet pool + * + * @generated from field: optional int64 pool_id = 1; + */ + poolId?: bigint; + + /** + * Raw pool URL as configured on the miner + * + * @generated from field: string url = 2; + */ + url: string; + + /** + * Username as configured on the miner + * + * @generated from field: string username = 3; + */ + username: string; +}; + +/** + * Describes the message fleetmanagement.v1.PoolAssignment. + * Use `create(PoolAssignmentSchema)` to create a new message. + */ +export const PoolAssignmentSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 9); + +/** + * Response containing the miner's current pool assignments + * Pools are returned in priority order: index 0 = default (priority 0), index 1 = backup1 (priority 1), etc. + * If a pool is configured on the miner but doesn't match any fleet pool, the pool_id will be absent + * + * @generated from message fleetmanagement.v1.GetMinerPoolAssignmentsResponse + */ +export type GetMinerPoolAssignmentsResponse = Message<"fleetmanagement.v1.GetMinerPoolAssignmentsResponse"> & { + /** + * Pool assignments in priority order (max 3: default, backup1, backup2) + * Empty if no pools are configured on the miner + * + * @generated from field: repeated fleetmanagement.v1.PoolAssignment pools = 1; + */ + pools: PoolAssignment[]; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerPoolAssignmentsResponse. + * Use `create(GetMinerPoolAssignmentsResponseSchema)` to create a new message. + */ +export const GetMinerPoolAssignmentsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 10); + +/** + * A single model group result with count + * + * @generated from message fleetmanagement.v1.MinerModelGroup + */ +export type MinerModelGroup = Message<"fleetmanagement.v1.MinerModelGroup"> & { + /** + * @generated from field: string model = 1; + */ + model: string; + + /** + * @generated from field: string manufacturer = 2; + */ + manufacturer: string; + + /** + * @generated from field: int32 count = 3; + */ + count: number; +}; + +/** + * Describes the message fleetmanagement.v1.MinerModelGroup. + * Use `create(MinerModelGroupSchema)` to create a new message. + */ +export const MinerModelGroupSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 11); + +/** + * Request to get miner model groups with counts + * + * @generated from message fleetmanagement.v1.GetMinerModelGroupsRequest + */ +export type GetMinerModelGroupsRequest = Message<"fleetmanagement.v1.GetMinerModelGroupsRequest"> & { + /** + * @generated from field: fleetmanagement.v1.MinerListFilter filter = 1; + */ + filter?: MinerListFilter; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerModelGroupsRequest. + * Use `create(GetMinerModelGroupsRequestSchema)` to create a new message. + */ +export const GetMinerModelGroupsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 12); + +/** + * Response containing miner model groups with counts + * + * @generated from message fleetmanagement.v1.GetMinerModelGroupsResponse + */ +export type GetMinerModelGroupsResponse = Message<"fleetmanagement.v1.GetMinerModelGroupsResponse"> & { + /** + * @generated from field: repeated fleetmanagement.v1.MinerModelGroup groups = 1; + */ + groups: MinerModelGroup[]; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerModelGroupsResponse. + * Use `create(GetMinerModelGroupsResponseSchema)` to create a new message. + */ +export const GetMinerModelGroupsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 13); + +/** + * Request to get the current cooling mode for a specific miner + * + * @generated from message fleetmanagement.v1.GetMinerCoolingModeRequest + */ +export type GetMinerCoolingModeRequest = Message<"fleetmanagement.v1.GetMinerCoolingModeRequest"> & { + /** + * Device identifier of the miner to get cooling mode for + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerCoolingModeRequest. + * Use `create(GetMinerCoolingModeRequestSchema)` to create a new message. + */ +export const GetMinerCoolingModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 14); + +/** + * Response containing the miner's current cooling mode + * + * @generated from message fleetmanagement.v1.GetMinerCoolingModeResponse + */ +export type GetMinerCoolingModeResponse = Message<"fleetmanagement.v1.GetMinerCoolingModeResponse"> & { + /** + * Current cooling mode of the miner + * UNSPECIFIED if the miner doesn't support cooling mode configuration + * An RPC error is returned if the miner could not be queried + * + * @generated from field: common.v1.CoolingMode cooling_mode = 1; + */ + coolingMode: CoolingMode; +}; + +/** + * Describes the message fleetmanagement.v1.GetMinerCoolingModeResponse. + * Use `create(GetMinerCoolingModeResponseSchema)` to create a new message. + */ +export const GetMinerCoolingModeResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 15); + +/** + * Selects devices for fleet management operations. + * Follows the same oneof pattern as minercommand.v1.DeviceSelector. + * + * @generated from message fleetmanagement.v1.DeviceSelector + */ +export type DeviceSelector = Message<"fleetmanagement.v1.DeviceSelector"> & { + /** + * @generated from oneof fleetmanagement.v1.DeviceSelector.selection_type + */ + selectionType: + | { + /** + * Select all paired devices, optionally filtered. + * An empty filter selects all paired devices in the org. + * + * @generated from field: fleetmanagement.v1.MinerListFilter all_devices = 1; + */ + value: MinerListFilter; + case: "allDevices"; + } + | { + /** + * Select specific devices by identifier. + * + * @generated from field: common.v1.DeviceIdentifierList include_devices = 2; + */ + value: DeviceIdentifierList; + case: "includeDevices"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetmanagement.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 16); + +/** + * Request to delete miners from the fleet. + * + * @generated from message fleetmanagement.v1.DeleteMinersRequest + */ +export type DeleteMinersRequest = Message<"fleetmanagement.v1.DeleteMinersRequest"> & { + /** + * @generated from field: fleetmanagement.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message fleetmanagement.v1.DeleteMinersRequest. + * Use `create(DeleteMinersRequestSchema)` to create a new message. + */ +export const DeleteMinersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 17); + +/** + * Response from deleting miners + * + * @generated from message fleetmanagement.v1.DeleteMinersResponse + */ +export type DeleteMinersResponse = Message<"fleetmanagement.v1.DeleteMinersResponse"> & { + /** + * Number of devices successfully deleted + * + * @generated from field: int32 deleted_count = 1; + */ + deletedCount: number; +}; + +/** + * Describes the message fleetmanagement.v1.DeleteMinersResponse. + * Use `create(DeleteMinersResponseSchema)` to create a new message. + */ +export const DeleteMinersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 18); + +/** + * Request to rename miners using a configurable name pattern. + * + * @generated from message fleetmanagement.v1.RenameMinersRequest + */ +export type RenameMinersRequest = Message<"fleetmanagement.v1.RenameMinersRequest"> & { + /** + * Selects which miners to rename (specific IDs or all devices). + * + * @generated from field: fleetmanagement.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Naming pattern applied to all selected miners. + * + * @generated from field: fleetmanagement.v1.MinerNameConfig name_config = 2; + */ + nameConfig?: MinerNameConfig; + + /** + * Current fleet table sort used to assign counter order during rename. + * If omitted, the backend falls back to the default name ascending order. + * + * @generated from field: repeated common.v1.SortConfig sort = 3; + */ + sort: SortConfig[]; +}; + +/** + * Describes the message fleetmanagement.v1.RenameMinersRequest. + * Use `create(RenameMinersRequestSchema)` to create a new message. + */ +export const RenameMinersRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 19); + +/** + * Response from renaming miners with per-device outcome counts for the batch. + * + * @generated from message fleetmanagement.v1.RenameMinersResponse + */ +export type RenameMinersResponse = Message<"fleetmanagement.v1.RenameMinersResponse"> & { + /** + * @generated from field: int32 renamed_count = 1; + */ + renamedCount: number; + + /** + * @generated from field: int32 unchanged_count = 2; + */ + unchangedCount: number; + + /** + * @generated from field: int32 failed_count = 3; + */ + failedCount: number; +}; + +/** + * Describes the message fleetmanagement.v1.RenameMinersResponse. + * Use `create(RenameMinersResponseSchema)` to create a new message. + */ +export const RenameMinersResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 20); + +/** + * Request to update worker names using a configurable name pattern. + * + * @generated from message fleetmanagement.v1.UpdateWorkerNamesRequest + */ +export type UpdateWorkerNamesRequest = Message<"fleetmanagement.v1.UpdateWorkerNamesRequest"> & { + /** + * Selects which miners to update (specific IDs or all devices). + * + * @generated from field: fleetmanagement.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Naming pattern applied to the worker name that will be pushed to each selected miner + * and persisted back to Fleet after the device update succeeds. + * + * @generated from field: fleetmanagement.v1.MinerNameConfig name_config = 2; + */ + nameConfig?: MinerNameConfig; + + /** + * Current fleet table sort used to assign counter order during generation. + * If omitted, the backend falls back to the default name ascending order. + * + * @generated from field: repeated common.v1.SortConfig sort = 3; + */ + sort: SortConfig[]; + + /** + * Fleet user's username for authorization. + * + * @generated from field: string user_username = 4; + */ + userUsername: string; + + /** + * Fleet user's password for authorization. + * + * @generated from field: string user_password = 5; + */ + userPassword: string; +}; + +/** + * Describes the message fleetmanagement.v1.UpdateWorkerNamesRequest. + * Use `create(UpdateWorkerNamesRequestSchema)` to create a new message. + */ +export const UpdateWorkerNamesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 21); + +/** + * Response from updating worker names. + * + * @generated from message fleetmanagement.v1.UpdateWorkerNamesResponse + */ +export type UpdateWorkerNamesResponse = Message<"fleetmanagement.v1.UpdateWorkerNamesResponse"> & { + /** + * @generated from field: int32 updated_count = 1; + */ + updatedCount: number; + + /** + * @generated from field: int32 unchanged_count = 2; + */ + unchangedCount: number; + + /** + * @generated from field: int32 failed_count = 3; + */ + failedCount: number; + + /** + * @generated from field: string batch_identifier = 4; + */ + batchIdentifier: string; +}; + +/** + * Describes the message fleetmanagement.v1.UpdateWorkerNamesResponse. + * Use `create(UpdateWorkerNamesResponseSchema)` to create a new message. + */ +export const UpdateWorkerNamesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 22); + +/** + * Configures how names are generated for a batch of miners. + * + * @generated from message fleetmanagement.v1.MinerNameConfig + */ +export type MinerNameConfig = Message<"fleetmanagement.v1.MinerNameConfig"> & { + /** + * Ordered list of properties that form the name. At least one is required. + * + * @generated from field: repeated fleetmanagement.v1.NameProperty properties = 1; + */ + properties: NameProperty[]; + + /** + * Separator placed between properties. Must be "-", "_", ".", or "" (no separator). + * + * @generated from field: string separator = 2; + */ + separator: string; +}; + +/** + * Describes the message fleetmanagement.v1.MinerNameConfig. + * Use `create(MinerNameConfigSchema)` to create a new message. + */ +export const MinerNameConfigSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 23); + +/** + * A single named segment in a MinerNameConfig. + * + * @generated from message fleetmanagement.v1.NameProperty + */ +export type NameProperty = Message<"fleetmanagement.v1.NameProperty"> & { + /** + * @generated from oneof fleetmanagement.v1.NameProperty.kind + */ + kind: + | { + /** + * @generated from field: fleetmanagement.v1.StringAndCounterProperty string_and_counter = 1; + */ + value: StringAndCounterProperty; + case: "stringAndCounter"; + } + | { + /** + * @generated from field: fleetmanagement.v1.CounterProperty counter = 2; + */ + value: CounterProperty; + case: "counter"; + } + | { + /** + * @generated from field: fleetmanagement.v1.StringProperty string_value = 3; + */ + value: StringProperty; + case: "stringValue"; + } + | { + /** + * @generated from field: fleetmanagement.v1.FixedValueProperty fixed_value = 4; + */ + value: FixedValueProperty; + case: "fixedValue"; + } + | { + /** + * @generated from field: fleetmanagement.v1.QualifierProperty qualifier = 5; + */ + value: QualifierProperty; + case: "qualifier"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetmanagement.v1.NameProperty. + * Use `create(NamePropertySchema)` to create a new message. + */ +export const NamePropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 24); + +/** + * Generates a segment combining an optional prefix/suffix with a zero-padded counter. + * + * @generated from message fleetmanagement.v1.StringAndCounterProperty + */ +export type StringAndCounterProperty = Message<"fleetmanagement.v1.StringAndCounterProperty"> & { + /** + * Optional prefix prepended to the counter. + * + * @generated from field: string prefix = 1; + */ + prefix: string; + + /** + * Optional suffix appended to the counter. + * + * @generated from field: string suffix = 2; + */ + suffix: string; + + /** + * Starting value for the counter. Must be >= 0. + * + * @generated from field: int32 counter_start = 3; + */ + counterStart: number; + + /** + * Number of digits in the counter (e.g. 3 → 001, 002, 003). Must be 1–6. + * + * @generated from field: int32 counter_scale = 4; + */ + counterScale: number; +}; + +/** + * Describes the message fleetmanagement.v1.StringAndCounterProperty. + * Use `create(StringAndCounterPropertySchema)` to create a new message. + */ +export const StringAndCounterPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 25); + +/** + * Generates a zero-padded counter segment with no surrounding text. + * + * @generated from message fleetmanagement.v1.CounterProperty + */ +export type CounterProperty = Message<"fleetmanagement.v1.CounterProperty"> & { + /** + * Starting value for the counter. Must be >= 0. + * + * @generated from field: int32 counter_start = 1; + */ + counterStart: number; + + /** + * Number of digits in the counter (e.g. 3 → 001, 002, 003). Must be 1–6. + * + * @generated from field: int32 counter_scale = 2; + */ + counterScale: number; +}; + +/** + * Describes the message fleetmanagement.v1.CounterProperty. + * Use `create(CounterPropertySchema)` to create a new message. + */ +export const CounterPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 26); + +/** + * Generates a static string segment. + * + * @generated from message fleetmanagement.v1.StringProperty + */ +export type StringProperty = Message<"fleetmanagement.v1.StringProperty"> & { + /** + * The string to insert. Must be non-empty. + * + * @generated from field: string value = 1; + */ + value: string; +}; + +/** + * Describes the message fleetmanagement.v1.StringProperty. + * Use `create(StringPropertySchema)` to create a new message. + */ +export const StringPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 27); + +/** + * Generates a segment from a device's fixed attribute value. + * + * @generated from message fleetmanagement.v1.FixedValueProperty + */ +export type FixedValueProperty = Message<"fleetmanagement.v1.FixedValueProperty"> & { + /** + * The device attribute to use. + * + * @generated from field: fleetmanagement.v1.FixedValueType type = 1; + */ + type: FixedValueType; + + /** + * Maximum number of characters to take from the attribute value. + * When set, section is required. Must be 1–6. + * + * @generated from field: optional int32 character_count = 2; + */ + characterCount?: number; + + /** + * Which end to take characters from. Required when character_count is set. + * + * @generated from field: optional fleetmanagement.v1.CharacterSection section = 3; + */ + section?: CharacterSection; +}; + +/** + * Describes the message fleetmanagement.v1.FixedValueProperty. + * Use `create(FixedValuePropertySchema)` to create a new message. + */ +export const FixedValuePropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 28); + +/** + * Generates a segment from a device's physical location qualifier. + * + * @generated from message fleetmanagement.v1.QualifierProperty + */ +export type QualifierProperty = Message<"fleetmanagement.v1.QualifierProperty"> & { + /** + * The location qualifier to use. + * + * @generated from field: fleetmanagement.v1.QualifierType type = 1; + */ + type: QualifierType; + + /** + * Optional prefix prepended to the qualifier value. + * + * @generated from field: string prefix = 2; + */ + prefix: string; + + /** + * Optional suffix appended to the qualifier value. + * + * @generated from field: string suffix = 3; + */ + suffix: string; +}; + +/** + * Describes the message fleetmanagement.v1.QualifierProperty. + * Use `create(QualifierPropertySchema)` to create a new message. + */ +export const QualifierPropertySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetmanagement_v1_fleetmanagement, 29); + +/** + * @generated from enum fleetmanagement.v1.FleetManagementServiceErrorCode + */ +export enum FleetManagementServiceErrorCode { + /** + * @generated from enum value: FLEET_MANAGEMENT_SERVICE_ERROR_CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: FLEET_MANAGEMENT_SERVICE_ERROR_CODE_INVALID_PAGINATION_CURSOR = 1; + */ + INVALID_PAGINATION_CURSOR = 1, +} + +/** + * Describes the enum fleetmanagement.v1.FleetManagementServiceErrorCode. + */ +export const FleetManagementServiceErrorCodeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 0); + +/** + * Status of a miner + * + * @generated from enum fleetmanagement.v1.DeviceStatus + */ +export enum DeviceStatus { + /** + * Status is unknown or not specified + * + * @generated from enum value: DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Miner is online and functioning normally + * + * @generated from enum value: DEVICE_STATUS_ONLINE = 1; + */ + ONLINE = 1, + + /** + * Miner is offline and not responding + * + * @generated from enum value: DEVICE_STATUS_OFFLINE = 2; + */ + OFFLINE = 2, + + /** + * Miner is in maintenance mode + * + * @generated from enum value: DEVICE_STATUS_MAINTENANCE = 3; + */ + MAINTENANCE = 3, + + /** + * Miner is in error state + * + * @generated from enum value: DEVICE_STATUS_ERROR = 4; + */ + ERROR = 4, + + /** + * Miner is inactive, not mining but still connected + * + * @generated from enum value: DEVICE_STATUS_INACTIVE = 5; + */ + INACTIVE = 5, + + /** + * Miner is online but needs a mining pool configured to start mining + * + * @generated from enum value: DEVICE_STATUS_NEEDS_MINING_POOL = 6; + */ + NEEDS_MINING_POOL = 6, + + /** + * Miner is receiving a firmware update (install in progress on device) + * + * @generated from enum value: DEVICE_STATUS_UPDATING = 7; + */ + UPDATING = 7, + + /** + * Miner firmware has been installed but requires a reboot to activate + * + * @generated from enum value: DEVICE_STATUS_REBOOT_REQUIRED = 8; + */ + REBOOT_REQUIRED = 8, +} + +/** + * Describes the enum fleetmanagement.v1.DeviceStatus. + */ +export const DeviceStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 1); + +/** + * Pairing status of a device + * + * @generated from enum fleetmanagement.v1.PairingStatus + */ +export enum PairingStatus { + /** + * Any pairing status, returns all devices (used in filters only) + * + * @generated from enum value: PAIRING_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Only paired devices + * + * @generated from enum value: PAIRING_STATUS_PAIRED = 1; + */ + PAIRED = 1, + + /** + * Only unpaired/discovered devices (no device entry exists) + * + * @generated from enum value: PAIRING_STATUS_UNPAIRED = 2; + */ + UNPAIRED = 2, + + /** + * Devices that require authentication credentials to complete pairing + * + * @generated from enum value: PAIRING_STATUS_AUTHENTICATION_NEEDED = 3; + */ + AUTHENTICATION_NEEDED = 3, + + /** + * Devices that are in the process of being paired + * + * @generated from enum value: PAIRING_STATUS_PENDING = 4; + */ + PENDING = 4, + + /** + * Devices that failed to pair + * + * @generated from enum value: PAIRING_STATUS_FAILED = 5; + */ + FAILED = 5, +} + +/** + * Describes the enum fleetmanagement.v1.PairingStatus. + */ +export const PairingStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 2); + +/** + * @generated from enum fleetmanagement.v1.CsvTemperatureUnit + */ +export enum CsvTemperatureUnit { + /** + * @generated from enum value: CSV_TEMPERATURE_UNIT_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CSV_TEMPERATURE_UNIT_CELSIUS = 1; + */ + CELSIUS = 1, + + /** + * @generated from enum value: CSV_TEMPERATURE_UNIT_FAHRENHEIT = 2; + */ + FAHRENHEIT = 2, +} + +/** + * Describes the enum fleetmanagement.v1.CsvTemperatureUnit. + */ +export const CsvTemperatureUnitSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 3); + +/** + * Device attribute used as a name segment. + * + * @generated from enum fleetmanagement.v1.FixedValueType + */ +export enum FixedValueType { + /** + * @generated from enum value: FIXED_VALUE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_MAC_ADDRESS = 1; + */ + MAC_ADDRESS = 1, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_SERIAL_NUMBER = 2; + */ + SERIAL_NUMBER = 2, + + /** + * Resolves to the worker name stored on fleet for the miner. + * + * @generated from enum value: FIXED_VALUE_TYPE_WORKER_NAME = 3; + */ + WORKER_NAME = 3, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_MODEL = 4; + */ + MODEL = 4, + + /** + * @generated from enum value: FIXED_VALUE_TYPE_MANUFACTURER = 5; + */ + MANUFACTURER = 5, + + /** + * Reserved — not yet implemented. + * + * @generated from enum value: FIXED_VALUE_TYPE_LOCATION = 6; + */ + LOCATION = 6, + + /** + * Resolves to the miner's current fleet name (custom name or manufacturer + model fallback). + * + * @generated from enum value: FIXED_VALUE_TYPE_MINER_NAME = 7; + */ + MINER_NAME = 7, +} + +/** + * Describes the enum fleetmanagement.v1.FixedValueType. + */ +export const FixedValueTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 4); + +/** + * Selects which end of a string to take characters from. + * + * @generated from enum fleetmanagement.v1.CharacterSection + */ +export enum CharacterSection { + /** + * @generated from enum value: CHARACTER_SECTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CHARACTER_SECTION_FIRST = 1; + */ + FIRST = 1, + + /** + * @generated from enum value: CHARACTER_SECTION_LAST = 2; + */ + LAST = 2, +} + +/** + * Describes the enum fleetmanagement.v1.CharacterSection. + */ +export const CharacterSectionSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 5); + +/** + * Physical location qualifier used as a name segment. + * + * @generated from enum fleetmanagement.v1.QualifierType + */ +export enum QualifierType { + /** + * @generated from enum value: QUALIFIER_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Reserved — not yet implemented. + * + * @generated from enum value: QUALIFIER_TYPE_BUILDING = 1; + */ + BUILDING = 1, + + /** + * Resolves to the assigned rack label. + * + * @generated from enum value: QUALIFIER_TYPE_RACK = 2; + */ + RACK = 2, + + /** + * Resolves to the assigned rack slot number. + * + * @generated from enum value: QUALIFIER_TYPE_RACK_POSITION = 3; + */ + RACK_POSITION = 3, +} + +/** + * Describes the enum fleetmanagement.v1.QualifierType. + */ +export const QualifierTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetmanagement_v1_fleetmanagement, 6); + +/** + * Service for managing fleet-wide settings and configurations + * + * @generated from service fleetmanagement.v1.FleetManagementService + */ +export const FleetManagementService: GenService<{ + /** + * List all devices in the fleet (paired and/or unpaired) optionally with their telemetry data + * Returns a paginated list of devices with their operational status and metrics + * Use the pairing_status filter to control whether to return paired, unpaired, or both + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.ListMinerStateSnapshots + */ + listMinerStateSnapshots: { + methodKind: "unary"; + input: typeof ListMinerStateSnapshotsRequestSchema; + output: typeof ListMinerStateSnapshotsResponseSchema; + }; + /** + * Export the paired miner list as a CSV snapshot using the provided filter. + * Rows are always emitted in default name-ascending order for cross-page consistency. + * The server paginates internally and streams CSV data in chunks. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.ExportMinerListCsv + */ + exportMinerListCsv: { + methodKind: "server_streaming"; + input: typeof ExportMinerListCsvRequestSchema; + output: typeof ExportMinerListCsvResponseSchema; + }; + /** + * Get counts of miners in different states without fetching miner data + * More efficient than ListMinerStateSnapshots when only counts are needed + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerStateCounts + */ + getMinerStateCounts: { + methodKind: "unary"; + input: typeof GetMinerStateCountsRequestSchema; + output: typeof GetMinerStateCountsResponseSchema; + }; + /** + * Get the current pool assignments for a specific miner + * Returns the fleet pool IDs that match the miner's currently configured pools + * Used to pre-populate the pool selection UI when editing a miner's pools + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerPoolAssignments + */ + getMinerPoolAssignments: { + methodKind: "unary"; + input: typeof GetMinerPoolAssignmentsRequestSchema; + output: typeof GetMinerPoolAssignmentsResponseSchema; + }; + /** + * Get the current cooling mode for a specific miner + * Returns the cooling mode configuration from the miner + * Used to pre-populate the cooling mode selection UI when editing a miner's settings + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerCoolingMode + */ + getMinerCoolingMode: { + methodKind: "unary"; + input: typeof GetMinerCoolingModeRequestSchema; + output: typeof GetMinerCoolingModeResponseSchema; + }; + /** + * Delete miners from the fleet by soft-deleting their database records. + * Immediately removes devices from the fleet and telemetry collection. + * Attempts best-effort ClearAuthKey on Proto rigs in the background. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.DeleteMiners + */ + deleteMiners: { + methodKind: "unary"; + input: typeof DeleteMinersRequestSchema; + output: typeof DeleteMinersResponseSchema; + }; + /** + * Get miner model groups with counts, optionally filtered by the current fleet filter + * Used for bulk password update to show accurate model groups across the full fleet + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.GetMinerModelGroups + */ + getMinerModelGroups: { + methodKind: "unary"; + input: typeof GetMinerModelGroupsRequestSchema; + output: typeof GetMinerModelGroupsResponseSchema; + }; + /** + * Rename miners by applying a name config to all selected devices. + * Supports both single-miner and bulk rename via DeviceSelector. + * Persists all names atomically in a single transaction. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.RenameMiners + */ + renameMiners: { + methodKind: "unary"; + input: typeof RenameMinersRequestSchema; + output: typeof RenameMinersResponseSchema; + }; + /** + * Update worker names by applying a name config to all selected devices. + * Reapplies the miners' current pool settings first, then persists worker names + * only for miners whose pool updates succeed so Fleet stays aligned with device state. + * + * @generated from rpc fleetmanagement.v1.FleetManagementService.UpdateWorkerNames + */ + updateWorkerNames: { + methodKind: "unary"; + input: typeof UpdateWorkerNamesRequestSchema; + output: typeof UpdateWorkerNamesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_fleetmanagement_v1_fleetmanagement, 0); diff --git a/client/src/protoFleet/api/generated/fleetperformance/v1/fleetperformance_pb.ts b/client/src/protoFleet/api/generated/fleetperformance/v1/fleetperformance_pb.ts new file mode 100644 index 000000000..1a855eec1 --- /dev/null +++ b/client/src/protoFleet/api/generated/fleetperformance/v1/fleetperformance_pb.ts @@ -0,0 +1,622 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file fleetperformance/v1/fleetperformance.proto (package fleetperformance.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Measurement } from "../../common/v1/measurement_pb"; +import { file_common_v1_measurement } from "../../common/v1/measurement_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file fleetperformance/v1/fleetperformance.proto. + */ +export const file_fleetperformance_v1_fleetperformance: GenFile = + /*@__PURE__*/ + fileDesc( + "CipmbGVldHBlcmZvcm1hbmNlL3YxL2ZsZWV0cGVyZm9ybWFuY2UucHJvdG8SE2ZsZWV0cGVyZm9ybWFuY2UudjEiHAoaR2V0RmxlZXRQZXJmb3JtYW5jZVJlcXVlc3QiXwobR2V0RmxlZXRQZXJmb3JtYW5jZVJlc3BvbnNlEkAKEWZsZWV0X3BlcmZvcm1hbmNlGAEgASgLMiUuZmxlZXRwZXJmb3JtYW5jZS52MS5GbGVldFBlcmZvcm1hbmNlIrcCChBGbGVldFBlcmZvcm1hbmNlEjQKCG92ZXJ2aWV3GAEgASgLMiIuZmxlZXRwZXJmb3JtYW5jZS52MS5GbGVldE92ZXJ2aWV3EjkKCGhhc2hyYXRlGAIgASgLMicuZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY3MSOwoKZWZmaWNpZW5jeRgDIAEoCzInLmZsZWV0cGVyZm9ybWFuY2UudjEuUGVyZm9ybWFuY2VNZXRyaWNzEjwKC3Bvd2VyX3VzYWdlGAQgASgLMicuZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY3MSNwoGdXB0aW1lGAUgASgLMicuZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY3MiUQoNRmxlZXRPdmVydmlldxIPCgdvZmZsaW5lGAEgASgFEhAKCGluYWN0aXZlGAIgASgFEg4KBmFjdGl2ZRgDIAEoBRINCgV0b3RhbBgEIAEoBSJkChJQZXJmb3JtYW5jZU1ldHJpY3MSKAoFc3RhdHMYASADKAsyGS5mbGVldHBlcmZvcm1hbmNlLnYxLlN0YXQSJAoEZGF0YRgCIAMoCzIWLmNvbW1vbi52MS5NZWFzdXJlbWVudCKDAQoEU3RhdBINCgVsYWJlbBgBIAEoCRIZCg9mb3JtYXR0ZWRfdmFsdWUYAiABKAlIABIzChFtZWFzdXJlbWVudF92YWx1ZRgDIAEoCzIWLmNvbW1vbi52MS5NZWFzdXJlbWVudEgAEhMKC2Rlc2NyaXB0aW9uGAQgASgJQgcKBXZhbHVlIkAKGlN0cmVhbUZsZWV0T3ZlcnZpZXdSZXF1ZXN0EiIKGmhlYXJ0YmVhdF9pbnRlcnZhbF9zZWNvbmRzGAEgASgFIskBChtTdHJlYW1GbGVldE92ZXJ2aWV3UmVzcG9uc2USLQoJdGltZXN0YW1wGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI8CghvdmVydmlldxgCIAEoCzIoLmZsZWV0cGVyZm9ybWFuY2UudjEuRmxlZXRPdmVydmlld1VwZGF0ZUgAEjMKCWhlYXJ0YmVhdBgDIAEoCzIeLmZsZWV0cGVyZm9ybWFuY2UudjEuSGVhcnRiZWF0SABCCAoGdXBkYXRlIksKE0ZsZWV0T3ZlcnZpZXdVcGRhdGUSNAoIb3ZlcnZpZXcYASABKAsyIi5mbGVldHBlcmZvcm1hbmNlLnYxLkZsZWV0T3ZlcnZpZXcivAEKH1N0cmVhbVBlcmZvcm1hbmNlTWV0cmljc1JlcXVlc3QSQAoMbWV0cmljX3R5cGVzGAEgAygOMiouZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY1R5cGUSFQoNaW5jbHVkZV9zdGF0cxgCIAEoCBIcChRpbmNsdWRlX21lYXN1cmVtZW50cxgDIAEoCBIiChpoZWFydGJlYXRfaW50ZXJ2YWxfc2Vjb25kcxgEIAEoBSLQAQogU3RyZWFtUGVyZm9ybWFuY2VNZXRyaWNzUmVzcG9uc2USLQoJdGltZXN0YW1wGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI+CgZtZXRyaWMYAiABKAsyLC5mbGVldHBlcmZvcm1hbmNlLnYxLlBlcmZvcm1hbmNlTWV0cmljVXBkYXRlSAASMwoJaGVhcnRiZWF0GAMgASgLMh4uZmxlZXRwZXJmb3JtYW5jZS52MS5IZWFydGJlYXRIAEIICgZ1cGRhdGUi2wEKF1BlcmZvcm1hbmNlTWV0cmljVXBkYXRlEj8KC21ldHJpY190eXBlGAEgASgOMiouZmxlZXRwZXJmb3JtYW5jZS52MS5QZXJmb3JtYW5jZU1ldHJpY1R5cGUSMQoFc3RhdHMYAiABKAsyIC5mbGVldHBlcmZvcm1hbmNlLnYxLlN0YXRzVXBkYXRlSAASPQoLbWVhc3VyZW1lbnQYAyABKAsyJi5mbGVldHBlcmZvcm1hbmNlLnYxLk1lYXN1cmVtZW50VXBkYXRlSABCDQoLdXBkYXRlX3R5cGUiNwoLU3RhdHNVcGRhdGUSKAoFc3RhdHMYASADKAsyGS5mbGVldHBlcmZvcm1hbmNlLnYxLlN0YXQiQAoRTWVhc3VyZW1lbnRVcGRhdGUSKwoLbWVhc3VyZW1lbnQYASABKAsyFi5jb21tb24udjEuTWVhc3VyZW1lbnQiCwoJSGVhcnRiZWF0KtsBChVQZXJmb3JtYW5jZU1ldHJpY1R5cGUSJwojUEVSRk9STUFOQ0VfTUVUUklDX1RZUEVfVU5TUEVDSUZJRUQQABIkCiBQRVJGT1JNQU5DRV9NRVRSSUNfVFlQRV9IQVNIUkFURRABEiYKIlBFUkZPUk1BTkNFX01FVFJJQ19UWVBFX0VGRklDSUVOQ1kQAhInCiNQRVJGT1JNQU5DRV9NRVRSSUNfVFlQRV9QT1dFUl9VU0FHRRADEiIKHlBFUkZPUk1BTkNFX01FVFJJQ19UWVBFX1VQVElNRRAEMpsDChdGbGVldFBlcmZvcm1hbmNlU2VydmljZRJ4ChNHZXRGbGVldFBlcmZvcm1hbmNlEi8uZmxlZXRwZXJmb3JtYW5jZS52MS5HZXRGbGVldFBlcmZvcm1hbmNlUmVxdWVzdBowLmZsZWV0cGVyZm9ybWFuY2UudjEuR2V0RmxlZXRQZXJmb3JtYW5jZVJlc3BvbnNlEnoKE1N0cmVhbUZsZWV0T3ZlcnZpZXcSLy5mbGVldHBlcmZvcm1hbmNlLnYxLlN0cmVhbUZsZWV0T3ZlcnZpZXdSZXF1ZXN0GjAuZmxlZXRwZXJmb3JtYW5jZS52MS5TdHJlYW1GbGVldE92ZXJ2aWV3UmVzcG9uc2UwARKJAQoYU3RyZWFtUGVyZm9ybWFuY2VNZXRyaWNzEjQuZmxlZXRwZXJmb3JtYW5jZS52MS5TdHJlYW1QZXJmb3JtYW5jZU1ldHJpY3NSZXF1ZXN0GjUuZmxlZXRwZXJmb3JtYW5jZS52MS5TdHJlYW1QZXJmb3JtYW5jZU1ldHJpY3NSZXNwb25zZTABQvgBChdjb20uZmxlZXRwZXJmb3JtYW5jZS52MUIVRmxlZXRwZXJmb3JtYW5jZVByb3RvUAFaWWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2ZsZWV0cGVyZm9ybWFuY2UvdjE7ZmxlZXRwZXJmb3JtYW5jZXYxogIDRlhYqgITRmxlZXRwZXJmb3JtYW5jZS5WMcoCE0ZsZWV0cGVyZm9ybWFuY2VcVjHiAh9GbGVldHBlcmZvcm1hbmNlXFYxXEdQQk1ldGFkYXRh6gIURmxlZXRwZXJmb3JtYW5jZTo6VjFiBnByb3RvMw", + [file_google_protobuf_timestamp, file_common_v1_measurement], + ); + +/** + * Request to retrieve fleet performance metrics + * + * Reserved for future use - currently returns default time range + * + * @generated from message fleetperformance.v1.GetFleetPerformanceRequest + */ +export type GetFleetPerformanceRequest = Message<"fleetperformance.v1.GetFleetPerformanceRequest"> & {}; + +/** + * Describes the message fleetperformance.v1.GetFleetPerformanceRequest. + * Use `create(GetFleetPerformanceRequestSchema)` to create a new message. + */ +export const GetFleetPerformanceRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 0); + +/** + * Response containing comprehensive fleet performance metrics + * + * @generated from message fleetperformance.v1.GetFleetPerformanceResponse + */ +export type GetFleetPerformanceResponse = Message<"fleetperformance.v1.GetFleetPerformanceResponse"> & { + /** + * Fleet performance data containing all metrics + * + * @generated from field: fleetperformance.v1.FleetPerformance fleet_performance = 1; + */ + fleetPerformance?: FleetPerformance; +}; + +/** + * Describes the message fleetperformance.v1.GetFleetPerformanceResponse. + * Use `create(GetFleetPerformanceResponseSchema)` to create a new message. + */ +export const GetFleetPerformanceResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 1); + +/** + * Comprehensive fleet performance metrics + * + * @generated from message fleetperformance.v1.FleetPerformance + */ +export type FleetPerformance = Message<"fleetperformance.v1.FleetPerformance"> & { + /** + * Overview statistics showing fleet status distribution + * + * @generated from field: fleetperformance.v1.FleetOverview overview = 1; + */ + overview?: FleetOverview; + + /** + * Hashrate performance metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics hashrate = 2; + */ + hashrate?: PerformanceMetrics; + + /** + * Energy efficiency metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics efficiency = 3; + */ + efficiency?: PerformanceMetrics; + + /** + * Power usage metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics power_usage = 4; + */ + powerUsage?: PerformanceMetrics; + + /** + * Uptime metrics and trends + * + * @generated from field: fleetperformance.v1.PerformanceMetrics uptime = 5; + */ + uptime?: PerformanceMetrics; +}; + +/** + * Describes the message fleetperformance.v1.FleetPerformance. + * Use `create(FleetPerformanceSchema)` to create a new message. + */ +export const FleetPerformanceSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 2); + +/** + * Fleet overview showing device status distribution + * + * @generated from message fleetperformance.v1.FleetOverview + */ +export type FleetOverview = Message<"fleetperformance.v1.FleetOverview"> & { + /** + * Number of devices that are offline + * + * @generated from field: int32 offline = 1; + */ + offline: number; + + /** + * Number of devices that are inactive + * + * @generated from field: int32 inactive = 2; + */ + inactive: number; + + /** + * Number of devices that are actively mining + * + * @generated from field: int32 active = 3; + */ + active: number; + + /** + * Total number of devices in the fleet + * + * @generated from field: int32 total = 4; + */ + total: number; +}; + +/** + * Describes the message fleetperformance.v1.FleetOverview. + * Use `create(FleetOverviewSchema)` to create a new message. + */ +export const FleetOverviewSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 3); + +/** + * Performance metrics containing statistics and time series data + * + * @generated from message fleetperformance.v1.PerformanceMetrics + */ +export type PerformanceMetrics = Message<"fleetperformance.v1.PerformanceMetrics"> & { + /** + * Statistical summary of the performance metric + * + * @generated from field: repeated fleetperformance.v1.Stat stats = 1; + */ + stats: Stat[]; + + /** + * Time series data points for the performance metric + * + * @generated from field: repeated common.v1.Measurement data = 2; + */ + data: Measurement[]; +}; + +/** + * Describes the message fleetperformance.v1.PerformanceMetrics. + * Use `create(PerformanceMetricsSchema)` to create a new message. + */ +export const PerformanceMetricsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 4); + +/** + * Statistical information about a performance metric + * + * @generated from message fleetperformance.v1.Stat + */ +export type Stat = Message<"fleetperformance.v1.Stat"> & { + /** + * Brief descriptive label for the statistic (e.g., "Average", "Peak", "Minimum") + * + * @generated from field: string label = 1; + */ + label: string; + + /** + * Value of the statistic - either formatted string or structured measurement + * + * @generated from oneof fleetperformance.v1.Stat.value + */ + value: + | { + /** + * Formatted value as a string (e.g., "125.5 TH/s", "98.2%") + * + * @generated from field: string formatted_value = 2; + */ + value: string; + case: "formattedValue"; + } + | { + /** + * Structured measurement with timestamp, value, and unit + * + * @generated from field: common.v1.Measurement measurement_value = 3; + */ + value: Measurement; + case: "measurementValue"; + } + | { case: undefined; value?: undefined }; + + /** + * Detailed description of what this statistic represents + * + * @generated from field: string description = 4; + */ + description: string; +}; + +/** + * Describes the message fleetperformance.v1.Stat. + * Use `create(StatSchema)` to create a new message. + */ +export const StatSchema: GenMessage = /*@__PURE__*/ messageDesc(file_fleetperformance_v1_fleetperformance, 5); + +/** + * Request to stream fleet overview updates + * + * @generated from message fleetperformance.v1.StreamFleetOverviewRequest + */ +export type StreamFleetOverviewRequest = Message<"fleetperformance.v1.StreamFleetOverviewRequest"> & { + /** + * Optional heartbeat interval in seconds (0 means no heartbeats) + * Heartbeats help detect connection issues and keep streams alive + * + * @generated from field: int32 heartbeat_interval_seconds = 1; + */ + heartbeatIntervalSeconds: number; +}; + +/** + * Describes the message fleetperformance.v1.StreamFleetOverviewRequest. + * Use `create(StreamFleetOverviewRequestSchema)` to create a new message. + */ +export const StreamFleetOverviewRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 6); + +/** + * Response containing fleet overview updates + * + * @generated from message fleetperformance.v1.StreamFleetOverviewResponse + */ +export type StreamFleetOverviewResponse = Message<"fleetperformance.v1.StreamFleetOverviewResponse"> & { + /** + * Timestamp when this update was generated + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Type of update + * + * @generated from oneof fleetperformance.v1.StreamFleetOverviewResponse.update + */ + update: + | { + /** + * Fleet overview status update + * + * @generated from field: fleetperformance.v1.FleetOverviewUpdate overview = 2; + */ + value: FleetOverviewUpdate; + case: "overview"; + } + | { + /** + * Heartbeat to keep connection alive (no data) + * + * @generated from field: fleetperformance.v1.Heartbeat heartbeat = 3; + */ + value: Heartbeat; + case: "heartbeat"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetperformance.v1.StreamFleetOverviewResponse. + * Use `create(StreamFleetOverviewResponseSchema)` to create a new message. + */ +export const StreamFleetOverviewResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 7); + +/** + * Fleet overview update containing the latest status distribution + * + * @generated from message fleetperformance.v1.FleetOverviewUpdate + */ +export type FleetOverviewUpdate = Message<"fleetperformance.v1.FleetOverviewUpdate"> & { + /** + * Updated fleet overview with current device status counts + * + * @generated from field: fleetperformance.v1.FleetOverview overview = 1; + */ + overview?: FleetOverview; +}; + +/** + * Describes the message fleetperformance.v1.FleetOverviewUpdate. + * Use `create(FleetOverviewUpdateSchema)` to create a new message. + */ +export const FleetOverviewUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 8); + +/** + * Request to stream performance metrics updates + * + * @generated from message fleetperformance.v1.StreamPerformanceMetricsRequest + */ +export type StreamPerformanceMetricsRequest = Message<"fleetperformance.v1.StreamPerformanceMetricsRequest"> & { + /** + * Types of performance metrics to stream + * If empty, streams all performance metric types + * + * @generated from field: repeated fleetperformance.v1.PerformanceMetricType metric_types = 1; + */ + metricTypes: PerformanceMetricType[]; + + /** + * Whether to include statistical summaries in updates + * If true, includes updated Stat arrays when statistics change + * + * @generated from field: bool include_stats = 2; + */ + includeStats: boolean; + + /** + * Whether to include individual measurement data points + * If true, includes new Measurement data as it becomes available + * + * @generated from field: bool include_measurements = 3; + */ + includeMeasurements: boolean; + + /** + * Optional heartbeat interval in seconds (0 means no heartbeats) + * Heartbeats help detect connection issues and keep streams alive + * + * @generated from field: int32 heartbeat_interval_seconds = 4; + */ + heartbeatIntervalSeconds: number; +}; + +/** + * Describes the message fleetperformance.v1.StreamPerformanceMetricsRequest. + * Use `create(StreamPerformanceMetricsRequestSchema)` to create a new message. + */ +export const StreamPerformanceMetricsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 9); + +/** + * Response containing performance metrics updates + * + * @generated from message fleetperformance.v1.StreamPerformanceMetricsResponse + */ +export type StreamPerformanceMetricsResponse = Message<"fleetperformance.v1.StreamPerformanceMetricsResponse"> & { + /** + * Timestamp when this update was generated + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Type of update + * + * @generated from oneof fleetperformance.v1.StreamPerformanceMetricsResponse.update + */ + update: + | { + /** + * Performance metric update + * + * @generated from field: fleetperformance.v1.PerformanceMetricUpdate metric = 2; + */ + value: PerformanceMetricUpdate; + case: "metric"; + } + | { + /** + * Heartbeat to keep connection alive (no data) + * + * @generated from field: fleetperformance.v1.Heartbeat heartbeat = 3; + */ + value: Heartbeat; + case: "heartbeat"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetperformance.v1.StreamPerformanceMetricsResponse. + * Use `create(StreamPerformanceMetricsResponseSchema)` to create a new message. + */ +export const StreamPerformanceMetricsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 10); + +/** + * Performance metric update for a specific metric type + * + * @generated from message fleetperformance.v1.PerformanceMetricUpdate + */ +export type PerformanceMetricUpdate = Message<"fleetperformance.v1.PerformanceMetricUpdate"> & { + /** + * The type of performance metric being updated + * + * @generated from field: fleetperformance.v1.PerformanceMetricType metric_type = 1; + */ + metricType: PerformanceMetricType; + + /** + * Type of metric update + * + * @generated from oneof fleetperformance.v1.PerformanceMetricUpdate.update_type + */ + updateType: + | { + /** + * Updated statistical summary + * + * @generated from field: fleetperformance.v1.StatsUpdate stats = 2; + */ + value: StatsUpdate; + case: "stats"; + } + | { + /** + * New measurement data point + * + * @generated from field: fleetperformance.v1.MeasurementUpdate measurement = 3; + */ + value: MeasurementUpdate; + case: "measurement"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message fleetperformance.v1.PerformanceMetricUpdate. + * Use `create(PerformanceMetricUpdateSchema)` to create a new message. + */ +export const PerformanceMetricUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 11); + +/** + * Updated statistical summary for a performance metric + * + * @generated from message fleetperformance.v1.StatsUpdate + */ +export type StatsUpdate = Message<"fleetperformance.v1.StatsUpdate"> & { + /** + * Updated statistics for the performance metric + * + * @generated from field: repeated fleetperformance.v1.Stat stats = 1; + */ + stats: Stat[]; +}; + +/** + * Describes the message fleetperformance.v1.StatsUpdate. + * Use `create(StatsUpdateSchema)` to create a new message. + */ +export const StatsUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 12); + +/** + * New measurement data point for a performance metric + * + * @generated from message fleetperformance.v1.MeasurementUpdate + */ +export type MeasurementUpdate = Message<"fleetperformance.v1.MeasurementUpdate"> & { + /** + * New measurement data point + * + * @generated from field: common.v1.Measurement measurement = 1; + */ + measurement?: Measurement; +}; + +/** + * Describes the message fleetperformance.v1.MeasurementUpdate. + * Use `create(MeasurementUpdateSchema)` to create a new message. + */ +export const MeasurementUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 13); + +/** + * Heartbeat message to keep streaming connections alive + * + * Empty message for heartbeat + * + * @generated from message fleetperformance.v1.Heartbeat + */ +export type Heartbeat = Message<"fleetperformance.v1.Heartbeat"> & {}; + +/** + * Describes the message fleetperformance.v1.Heartbeat. + * Use `create(HeartbeatSchema)` to create a new message. + */ +export const HeartbeatSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_fleetperformance_v1_fleetperformance, 14); + +/** + * Types of performance metrics available for streaming + * + * @generated from enum fleetperformance.v1.PerformanceMetricType + */ +export enum PerformanceMetricType { + /** + * Metric type not specified + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Hashrate performance metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_HASHRATE = 1; + */ + HASHRATE = 1, + + /** + * Energy efficiency metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_EFFICIENCY = 2; + */ + EFFICIENCY = 2, + + /** + * Power usage metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_POWER_USAGE = 3; + */ + POWER_USAGE = 3, + + /** + * Uptime metrics + * + * @generated from enum value: PERFORMANCE_METRIC_TYPE_UPTIME = 4; + */ + UPTIME = 4, +} + +/** + * Describes the enum fleetperformance.v1.PerformanceMetricType. + */ +export const PerformanceMetricTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_fleetperformance_v1_fleetperformance, 0); + +/** + * FleetPerformanceService provides fleet-wide performance metrics and analytics + * + * @generated from service fleetperformance.v1.FleetPerformanceService + */ +export const FleetPerformanceService: GenService<{ + /** + * GetFleetPerformance retrieves comprehensive performance metrics for the entire fleet + * Returns aggregated statistics and time series data for various performance indicators + * + * @generated from rpc fleetperformance.v1.FleetPerformanceService.GetFleetPerformance + */ + getFleetPerformance: { + methodKind: "unary"; + input: typeof GetFleetPerformanceRequestSchema; + output: typeof GetFleetPerformanceResponseSchema; + }; + /** + * StreamFleetOverview provides real-time updates for fleet overview statistics + * Returns a continuous stream of fleet status distribution changes + * + * @generated from rpc fleetperformance.v1.FleetPerformanceService.StreamFleetOverview + */ + streamFleetOverview: { + methodKind: "server_streaming"; + input: typeof StreamFleetOverviewRequestSchema; + output: typeof StreamFleetOverviewResponseSchema; + }; + /** + * StreamPerformanceMetrics provides real-time updates for specific performance metrics + * Returns a continuous stream of metric updates for subscribed performance types + * + * @generated from rpc fleetperformance.v1.FleetPerformanceService.StreamPerformanceMetrics + */ + streamPerformanceMetrics: { + methodKind: "server_streaming"; + input: typeof StreamPerformanceMetricsRequestSchema; + output: typeof StreamPerformanceMetricsResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_fleetperformance_v1_fleetperformance, 0); diff --git a/client/src/protoFleet/api/generated/foremanimport/v1/foremanimport_pb.ts b/client/src/protoFleet/api/generated/foremanimport/v1/foremanimport_pb.ts new file mode 100644 index 000000000..e5729685b --- /dev/null +++ b/client/src/protoFleet/api/generated/foremanimport/v1/foremanimport_pb.ts @@ -0,0 +1,222 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file foremanimport/v1/foremanimport.proto (package foremanimport.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file foremanimport/v1/foremanimport.proto. + */ +export const file_foremanimport_v1_foremanimport: GenFile = + /*@__PURE__*/ + fileDesc( + "CiRmb3JlbWFuaW1wb3J0L3YxL2ZvcmVtYW5pbXBvcnQucHJvdG8SEGZvcmVtYW5pbXBvcnQudjEiOAoSRm9yZW1hbkNyZWRlbnRpYWxzEg8KB2FwaV9rZXkYASABKAkSEQoJY2xpZW50X2lkGAIgASgJIlUKGEltcG9ydEZyb21Gb3JlbWFuUmVxdWVzdBI5CgtjcmVkZW50aWFscxgBIAEoCzIkLmZvcmVtYW5pbXBvcnQudjEuRm9yZW1hbkNyZWRlbnRpYWxzIlQKDEZvcmVtYW5NaW5lchISCgppcF9hZGRyZXNzGAEgASgJEhMKC21hY19hZGRyZXNzGAIgASgJEgwKBG5hbWUYAyABKAkSDQoFbW9kZWwYBCABKAkiSwoZSW1wb3J0RnJvbUZvcmVtYW5SZXNwb25zZRIuCgZtaW5lcnMYASADKAsyHi5mb3JlbWFuaW1wb3J0LnYxLkZvcmVtYW5NaW5lciK4AQoVQ29tcGxldGVJbXBvcnRSZXF1ZXN0EjkKC2NyZWRlbnRpYWxzGAEgASgLMiQuZm9yZW1hbmltcG9ydC52MS5Gb3JlbWFuQ3JlZGVudGlhbHMSFAoMaW1wb3J0X3Bvb2xzGAIgASgIEhUKDWltcG9ydF9ncm91cHMYAyABKAgSFAoMaW1wb3J0X3JhY2tzGAQgASgIEiEKGXBhaXJlZF9kZXZpY2VfaWRlbnRpZmllcnMYBSADKAkiqwEKFkNvbXBsZXRlSW1wb3J0UmVzcG9uc2USFQoNcG9vbHNfY3JlYXRlZBgBIAEoBRIWCg5ncm91cHNfY3JlYXRlZBgCIAEoBRIVCg1yYWNrc19jcmVhdGVkGAMgASgFEhgKEGRldmljZXNfYXNzaWduZWQYBCABKAUSGAoQd29ya2VyX25hbWVzX3NldBgFIAEoBRIXCg9taW5lcl9uYW1lc19zZXQYBiABKAUy6QEKFEZvcmVtYW5JbXBvcnRTZXJ2aWNlEmwKEUltcG9ydEZyb21Gb3JlbWFuEiouZm9yZW1hbmltcG9ydC52MS5JbXBvcnRGcm9tRm9yZW1hblJlcXVlc3QaKy5mb3JlbWFuaW1wb3J0LnYxLkltcG9ydEZyb21Gb3JlbWFuUmVzcG9uc2USYwoOQ29tcGxldGVJbXBvcnQSJy5mb3JlbWFuaW1wb3J0LnYxLkNvbXBsZXRlSW1wb3J0UmVxdWVzdBooLmZvcmVtYW5pbXBvcnQudjEuQ29tcGxldGVJbXBvcnRSZXNwb25zZULgAQoUY29tLmZvcmVtYW5pbXBvcnQudjFCEkZvcmVtYW5pbXBvcnRQcm90b1ABWlNnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9mb3JlbWFuaW1wb3J0L3YxO2ZvcmVtYW5pbXBvcnR2MaICA0ZYWKoCEEZvcmVtYW5pbXBvcnQuVjHKAhBGb3JlbWFuaW1wb3J0XFYx4gIcRm9yZW1hbmltcG9ydFxWMVxHUEJNZXRhZGF0YeoCEUZvcmVtYW5pbXBvcnQ6OlYxYgZwcm90bzM", + ); + +/** + * @generated from message foremanimport.v1.ForemanCredentials + */ +export type ForemanCredentials = Message<"foremanimport.v1.ForemanCredentials"> & { + /** + * @generated from field: string api_key = 1; + */ + apiKey: string; + + /** + * @generated from field: string client_id = 2; + */ + clientId: string; +}; + +/** + * Describes the message foremanimport.v1.ForemanCredentials. + * Use `create(ForemanCredentialsSchema)` to create a new message. + */ +export const ForemanCredentialsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 0); + +/** + * @generated from message foremanimport.v1.ImportFromForemanRequest + */ +export type ImportFromForemanRequest = Message<"foremanimport.v1.ImportFromForemanRequest"> & { + /** + * @generated from field: foremanimport.v1.ForemanCredentials credentials = 1; + */ + credentials?: ForemanCredentials; +}; + +/** + * Describes the message foremanimport.v1.ImportFromForemanRequest. + * Use `create(ImportFromForemanRequestSchema)` to create a new message. + */ +export const ImportFromForemanRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 1); + +/** + * @generated from message foremanimport.v1.ForemanMiner + */ +export type ForemanMiner = Message<"foremanimport.v1.ForemanMiner"> & { + /** + * @generated from field: string ip_address = 1; + */ + ipAddress: string; + + /** + * @generated from field: string mac_address = 2; + */ + macAddress: string; + + /** + * @generated from field: string name = 3; + */ + name: string; + + /** + * @generated from field: string model = 4; + */ + model: string; +}; + +/** + * Describes the message foremanimport.v1.ForemanMiner. + * Use `create(ForemanMinerSchema)` to create a new message. + */ +export const ForemanMinerSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 2); + +/** + * @generated from message foremanimport.v1.ImportFromForemanResponse + */ +export type ImportFromForemanResponse = Message<"foremanimport.v1.ImportFromForemanResponse"> & { + /** + * @generated from field: repeated foremanimport.v1.ForemanMiner miners = 1; + */ + miners: ForemanMiner[]; +}; + +/** + * Describes the message foremanimport.v1.ImportFromForemanResponse. + * Use `create(ImportFromForemanResponseSchema)` to create a new message. + */ +export const ImportFromForemanResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 3); + +/** + * @generated from message foremanimport.v1.CompleteImportRequest + */ +export type CompleteImportRequest = Message<"foremanimport.v1.CompleteImportRequest"> & { + /** + * @generated from field: foremanimport.v1.ForemanCredentials credentials = 1; + */ + credentials?: ForemanCredentials; + + /** + * Toggle which entity types to import. + * + * @generated from field: bool import_pools = 2; + */ + importPools: boolean; + + /** + * @generated from field: bool import_groups = 3; + */ + importGroups: boolean; + + /** + * @generated from field: bool import_racks = 4; + */ + importRacks: boolean; + + /** + * Only process Foreman miners whose IPs were paired as these device identifiers. + * If empty, all Foreman miners are processed (backward compatible). + * + * @generated from field: repeated string paired_device_identifiers = 5; + */ + pairedDeviceIdentifiers: string[]; +}; + +/** + * Describes the message foremanimport.v1.CompleteImportRequest. + * Use `create(CompleteImportRequestSchema)` to create a new message. + */ +export const CompleteImportRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 4); + +/** + * @generated from message foremanimport.v1.CompleteImportResponse + */ +export type CompleteImportResponse = Message<"foremanimport.v1.CompleteImportResponse"> & { + /** + * @generated from field: int32 pools_created = 1; + */ + poolsCreated: number; + + /** + * @generated from field: int32 groups_created = 2; + */ + groupsCreated: number; + + /** + * @generated from field: int32 racks_created = 3; + */ + racksCreated: number; + + /** + * @generated from field: int32 devices_assigned = 4; + */ + devicesAssigned: number; + + /** + * @generated from field: int32 worker_names_set = 5; + */ + workerNamesSet: number; + + /** + * @generated from field: int32 miner_names_set = 6; + */ + minerNamesSet: number; +}; + +/** + * Describes the message foremanimport.v1.CompleteImportResponse. + * Use `create(CompleteImportResponseSchema)` to create a new message. + */ +export const CompleteImportResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_foremanimport_v1_foremanimport, 5); + +/** + * @generated from service foremanimport.v1.ForemanImportService + */ +export const ForemanImportService: GenService<{ + /** + * Validates Foreman credentials and returns miner IPs for discovery+pairing. + * Does NOT create pools/groups/racks — call CompleteImport after pairing. + * + * @generated from rpc foremanimport.v1.ForemanImportService.ImportFromForeman + */ + importFromForeman: { + methodKind: "unary"; + input: typeof ImportFromForemanRequestSchema; + output: typeof ImportFromForemanResponseSchema; + }; + /** + * Creates pools/groups/racks and assigns paired devices to their collections. + * Call this after miners from ImportFromForeman have been discovered and paired. + * + * @generated from rpc foremanimport.v1.ForemanImportService.CompleteImport + */ + completeImport: { + methodKind: "unary"; + input: typeof CompleteImportRequestSchema; + output: typeof CompleteImportResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_foremanimport_v1_foremanimport, 0); diff --git a/client/src/protoFleet/api/generated/minercommand/v1/command_pb.ts b/client/src/protoFleet/api/generated/minercommand/v1/command_pb.ts new file mode 100644 index 000000000..160aef0b8 --- /dev/null +++ b/client/src/protoFleet/api/generated/minercommand/v1/command_pb.ts @@ -0,0 +1,1172 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file minercommand/v1/command.proto (package minercommand.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { DeviceStatus, PairingStatus } from "../../fleetmanagement/v1/fleetmanagement_pb"; +import { file_fleetmanagement_v1_fleetmanagement } from "../../fleetmanagement/v1/fleetmanagement_pb"; +import type { DeviceIdentifierList } from "../../common/v1/device_selector_pb"; +import { file_common_v1_device_selector } from "../../common/v1/device_selector_pb"; +import type { CoolingMode } from "../../common/v1/cooling_pb"; +import { file_common_v1_cooling } from "../../common/v1/cooling_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file minercommand/v1/command.proto. + */ +export const file_minercommand_v1_command: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch1taW5lcmNvbW1hbmQvdjEvY29tbWFuZC5wcm90bxIPbWluZXJjb21tYW5kLnYxIqkBCgxEZXZpY2VGaWx0ZXISNwoNZGV2aWNlX3N0YXR1cxgBIAMoDjIgLmZsZWV0bWFuYWdlbWVudC52MS5EZXZpY2VTdGF0dXMSOQoOcGFpcmluZ19zdGF0dXMYAiADKA4yIS5mbGVldG1hbmFnZW1lbnQudjEuUGFpcmluZ1N0YXR1cxIOCgZtb2RlbHMYAyADKAkSFQoNbWFudWZhY3R1cmVycxgEIAMoCSKUAQoORGV2aWNlU2VsZWN0b3ISNAoLYWxsX2RldmljZXMYASABKAsyHS5taW5lcmNvbW1hbmQudjEuRGV2aWNlRmlsdGVySAASOgoPaW5jbHVkZV9kZXZpY2VzGAIgASgLMh8uY29tbW9uLnYxLkRldmljZUlkZW50aWZpZXJMaXN0SABCEAoOc2VsZWN0aW9uX3R5cGUiSQoNUmVib290UmVxdWVzdBI4Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHy5taW5lcmNvbW1hbmQudjEuRGV2aWNlU2VsZWN0b3IiKgoOUmVib290UmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSJNChFTdG9wTWluaW5nUmVxdWVzdBI4Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHy5taW5lcmNvbW1hbmQudjEuRGV2aWNlU2VsZWN0b3IiLgoSU3RvcE1pbmluZ1Jlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiTgoSU3RhcnRNaW5pbmdSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvciIvChNTdGFydE1pbmluZ1Jlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkidwoVU2V0Q29vbGluZ01vZGVSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvchIkCgRtb2RlGAIgASgOMhYuY29tbW9uLnYxLkNvb2xpbmdNb2RlIjIKFlNldENvb2xpbmdNb2RlUmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSKNAQoVU2V0UG93ZXJUYXJnZXRSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvchI6ChBwZXJmb3JtYW5jZV9tb2RlGAIgASgOMiAubWluZXJjb21tYW5kLnYxLlBlcmZvcm1hbmNlTW9kZSIyChZTZXRQb3dlclRhcmdldFJlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiZAoOUG9vbFNsb3RDb25maWcSEQoHcG9vbF9pZBgBIAEoA0gAEjAKCHJhd19wb29sGAIgASgLMhwubWluZXJjb21tYW5kLnYxLlJhd1Bvb2xJbmZvSABCDQoLcG9vbF9zb3VyY2UiUAoLUmF3UG9vbEluZm8SCwoDdXJsGAEgASgJEhAKCHVzZXJuYW1lGAIgASgJEhUKCHBhc3N3b3JkGAMgASgJSACIAQFCCwoJX3Bhc3N3b3JkItcCChhVcGRhdGVNaW5pbmdQb29sc1JlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yEjUKDGRlZmF1bHRfcG9vbBgCIAEoCzIfLm1pbmVyY29tbWFuZC52MS5Qb29sU2xvdENvbmZpZxI7Cg1iYWNrdXBfMV9wb29sGAMgASgLMh8ubWluZXJjb21tYW5kLnYxLlBvb2xTbG90Q29uZmlnSACIAQESOwoNYmFja3VwXzJfcG9vbBgEIAEoCzIfLm1pbmVyY29tbWFuZC52MS5Qb29sU2xvdENvbmZpZ0gBiAEBEhUKDXVzZXJfdXNlcm5hbWUYBSABKAkSFQoNdXNlcl9wYXNzd29yZBgGIAEoCUIQCg5fYmFja3VwXzFfcG9vbEIQCg5fYmFja3VwXzJfcG9vbCI1ChlVcGRhdGVNaW5pbmdQb29sc1Jlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiTwoTRG93bmxvYWRMb2dzUmVxdWVzdBI4Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHy5taW5lcmNvbW1hbmQudjEuRGV2aWNlU2VsZWN0b3IiMAoURG93bmxvYWRMb2dzUmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSJLCg9CbGlua0xFRFJlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yIiwKEEJsaW5rTEVEUmVzcG9uc2USGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSJrChVGaXJtd2FyZVVwZGF0ZVJlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yEhgKEGZpcm13YXJlX2ZpbGVfaWQYAiABKAkiMgoWRmlybXdhcmVVcGRhdGVSZXNwb25zZRIYChBiYXRjaF9pZGVudGlmaWVyGAEgASgJIkkKDVVucGFpclJlcXVlc3QSOAoPZGV2aWNlX3NlbGVjdG9yGAEgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yIioKDlVucGFpclJlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkitAEKGlVwZGF0ZU1pbmVyUGFzc3dvcmRSZXF1ZXN0EjgKD2RldmljZV9zZWxlY3RvchgBIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvchIUCgxuZXdfcGFzc3dvcmQYAiABKAkSGAoQY3VycmVudF9wYXNzd29yZBgDIAEoCRIVCg11c2VyX3VzZXJuYW1lGAQgASgJEhUKDXVzZXJfcGFzc3dvcmQYBSABKAkiNwobVXBkYXRlTWluZXJQYXNzd29yZFJlc3BvbnNlEhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiPAogU3RyZWFtQ29tbWFuZEJhdGNoVXBkYXRlc1JlcXVlc3QSGAoQYmF0Y2hfaWRlbnRpZmllchgBIAEoCSKvAQohU3RyZWFtQ29tbWFuZEJhdGNoVXBkYXRlc1Jlc3BvbnNlEi0KCXRpbWVzdGFtcBgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASIAoYY29tbWFuZF9iYXRjaF9pZGVudGlmaWVyGAIgASgJEjkKBnN0YXR1cxgDIAEoCzIpLm1pbmVyY29tbWFuZC52MS5Db21tYW5kQmF0Y2hVcGRhdGVTdGF0dXMimAEKHUNvbW1hbmRCYXRjaFVwZGF0ZURldmljZUNvdW50Eg0KBXRvdGFsGAEgASgDEg8KB3N1Y2Nlc3MYAiABKAMSDwoHZmFpbHVyZRgDIAEoAxIiChpzdWNjZXNzX2RldmljZV9pZGVudGlmaWVycxgEIAMoCRIiChpmYWlsdXJlX2RldmljZV9pZGVudGlmaWVycxgFIAMoCSK8AwoYQ29tbWFuZEJhdGNoVXBkYXRlU3RhdHVzEmsKG2NvbW1hbmRfYmF0Y2hfdXBkYXRlX3N0YXR1cxgBIAEoDjJGLm1pbmVyY29tbWFuZC52MS5Db21tYW5kQmF0Y2hVcGRhdGVTdGF0dXMuQ29tbWFuZEJhdGNoVXBkYXRlU3RhdHVzVHlwZRJSChpjb21tYW5kX2JhdGNoX2RldmljZV9jb3VudBgCIAEoCzIuLm1pbmVyY29tbWFuZC52MS5Db21tYW5kQmF0Y2hVcGRhdGVEZXZpY2VDb3VudCLeAQocQ29tbWFuZEJhdGNoVXBkYXRlU3RhdHVzVHlwZRIwCixDT01NQU5EX0JBVENIX1VQREFURV9TVEFUVVNfVFlQRV9VTlNQRUNJRklFRBAAEiwKKENPTU1BTkRfQkFUQ0hfVVBEQVRFX1NUQVRVU19UWVBFX1BFTkRJTkcQARIvCitDT01NQU5EX0JBVENIX1VQREFURV9TVEFUVVNfVFlQRV9QUk9DRVNTSU5HEAISLQopQ09NTUFORF9CQVRDSF9VUERBVEVfU1RBVFVTX1RZUEVfRklOSVNIRUQQAyI7Ch9HZXRDb21tYW5kQmF0Y2hMb2dCdW5kbGVSZXF1ZXN0EhgKEGJhdGNoX2lkZW50aWZpZXIYASABKAkiSAogR2V0Q29tbWFuZEJhdGNoTG9nQnVuZGxlUmVzcG9uc2USEgoKY2h1bmtfZGF0YRgBIAEoDBIQCghmaWxlbmFtZRgCIAEoCSKPAQofQ2hlY2tDb21tYW5kQ2FwYWJpbGl0aWVzUmVxdWVzdBIyCgxjb21tYW5kX3R5cGUYASABKA4yHC5taW5lcmNvbW1hbmQudjEuQ29tbWFuZFR5cGUSOAoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMh8ubWluZXJjb21tYW5kLnYxLkRldmljZVNlbGVjdG9yIk8KFVVuc3VwcG9ydGVkTWluZXJHcm91cBIYChBmaXJtd2FyZV92ZXJzaW9uGAEgASgJEg0KBW1vZGVsGAIgASgJEg0KBWNvdW50GAMgASgFIoQCCiBDaGVja0NvbW1hbmRDYXBhYmlsaXRpZXNSZXNwb25zZRIXCg9zdXBwb3J0ZWRfY291bnQYASABKAUSGQoRdW5zdXBwb3J0ZWRfY291bnQYAiABKAUSEwoLdG90YWxfY291bnQYAyABKAUSFQoNYWxsX3N1cHBvcnRlZBgEIAEoCBIWCg5ub25lX3N1cHBvcnRlZBgFIAEoCBJCChJ1bnN1cHBvcnRlZF9ncm91cHMYBiADKAsyJi5taW5lcmNvbW1hbmQudjEuVW5zdXBwb3J0ZWRNaW5lckdyb3VwEiQKHHN1cHBvcnRlZF9kZXZpY2VfaWRlbnRpZmllcnMYByADKAkqewoPUGVyZm9ybWFuY2VNb2RlEiAKHFBFUkZPUk1BTkNFX01PREVfVU5TUEVDSUZJRUQQABIlCiFQRVJGT1JNQU5DRV9NT0RFX01BWElNVU1fSEFTSFJBVEUQARIfChtQRVJGT1JNQU5DRV9NT0RFX0VGRklDSUVOQ1kQAirzAgoLQ29tbWFuZFR5cGUSHAoYQ09NTUFORF9UWVBFX1VOU1BFQ0lGSUVEEAASFwoTQ09NTUFORF9UWVBFX1JFQk9PVBABEh0KGUNPTU1BTkRfVFlQRV9TVEFSVF9NSU5JTkcQAhIcChhDT01NQU5EX1RZUEVfU1RPUF9NSU5JTkcQAxIaChZDT01NQU5EX1RZUEVfQkxJTktfTEVEEAQSIQodQ09NTUFORF9UWVBFX1NFVF9DT09MSU5HX01PREUQBRIkCiBDT01NQU5EX1RZUEVfVVBEQVRFX01JTklOR19QT09MUxAGEh4KGkNPTU1BTkRfVFlQRV9ET1dOTE9BRF9MT0dTEAcSIAocQ09NTUFORF9UWVBFX0ZJUk1XQVJFX1VQREFURRAIEiEKHUNPTU1BTkRfVFlQRV9TRVRfUE9XRVJfVEFSR0VUEAkSJgoiQ09NTUFORF9UWVBFX1VQREFURV9NSU5FUl9QQVNTV09SRBAKMpoLChNNaW5lckNvbW1hbmRTZXJ2aWNlEkkKBlJlYm9vdBIeLm1pbmVyY29tbWFuZC52MS5SZWJvb3RSZXF1ZXN0Gh8ubWluZXJjb21tYW5kLnYxLlJlYm9vdFJlc3BvbnNlElUKClN0b3BNaW5pbmcSIi5taW5lcmNvbW1hbmQudjEuU3RvcE1pbmluZ1JlcXVlc3QaIy5taW5lcmNvbW1hbmQudjEuU3RvcE1pbmluZ1Jlc3BvbnNlElgKC1N0YXJ0TWluaW5nEiMubWluZXJjb21tYW5kLnYxLlN0YXJ0TWluaW5nUmVxdWVzdBokLm1pbmVyY29tbWFuZC52MS5TdGFydE1pbmluZ1Jlc3BvbnNlEmEKDlNldENvb2xpbmdNb2RlEiYubWluZXJjb21tYW5kLnYxLlNldENvb2xpbmdNb2RlUmVxdWVzdBonLm1pbmVyY29tbWFuZC52MS5TZXRDb29saW5nTW9kZVJlc3BvbnNlEmEKDlNldFBvd2VyVGFyZ2V0EiYubWluZXJjb21tYW5kLnYxLlNldFBvd2VyVGFyZ2V0UmVxdWVzdBonLm1pbmVyY29tbWFuZC52MS5TZXRQb3dlclRhcmdldFJlc3BvbnNlEmoKEVVwZGF0ZU1pbmluZ1Bvb2xzEikubWluZXJjb21tYW5kLnYxLlVwZGF0ZU1pbmluZ1Bvb2xzUmVxdWVzdBoqLm1pbmVyY29tbWFuZC52MS5VcGRhdGVNaW5pbmdQb29sc1Jlc3BvbnNlElsKDERvd25sb2FkTG9ncxIkLm1pbmVyY29tbWFuZC52MS5Eb3dubG9hZExvZ3NSZXF1ZXN0GiUubWluZXJjb21tYW5kLnYxLkRvd25sb2FkTG9nc1Jlc3BvbnNlEk8KCEJsaW5rTEVEEiAubWluZXJjb21tYW5kLnYxLkJsaW5rTEVEUmVxdWVzdBohLm1pbmVyY29tbWFuZC52MS5CbGlua0xFRFJlc3BvbnNlEoQBChlTdHJlYW1Db21tYW5kQmF0Y2hVcGRhdGVzEjEubWluZXJjb21tYW5kLnYxLlN0cmVhbUNvbW1hbmRCYXRjaFVwZGF0ZXNSZXF1ZXN0GjIubWluZXJjb21tYW5kLnYxLlN0cmVhbUNvbW1hbmRCYXRjaFVwZGF0ZXNSZXNwb25zZTABEn8KGEdldENvbW1hbmRCYXRjaExvZ0J1bmRsZRIwLm1pbmVyY29tbWFuZC52MS5HZXRDb21tYW5kQmF0Y2hMb2dCdW5kbGVSZXF1ZXN0GjEubWluZXJjb21tYW5kLnYxLkdldENvbW1hbmRCYXRjaExvZ0J1bmRsZVJlc3BvbnNlEmEKDkZpcm13YXJlVXBkYXRlEiYubWluZXJjb21tYW5kLnYxLkZpcm13YXJlVXBkYXRlUmVxdWVzdBonLm1pbmVyY29tbWFuZC52MS5GaXJtd2FyZVVwZGF0ZVJlc3BvbnNlEkkKBlVucGFpchIeLm1pbmVyY29tbWFuZC52MS5VbnBhaXJSZXF1ZXN0Gh8ubWluZXJjb21tYW5kLnYxLlVucGFpclJlc3BvbnNlEnAKE1VwZGF0ZU1pbmVyUGFzc3dvcmQSKy5taW5lcmNvbW1hbmQudjEuVXBkYXRlTWluZXJQYXNzd29yZFJlcXVlc3QaLC5taW5lcmNvbW1hbmQudjEuVXBkYXRlTWluZXJQYXNzd29yZFJlc3BvbnNlEn8KGENoZWNrQ29tbWFuZENhcGFiaWxpdGllcxIwLm1pbmVyY29tbWFuZC52MS5DaGVja0NvbW1hbmRDYXBhYmlsaXRpZXNSZXF1ZXN0GjEubWluZXJjb21tYW5kLnYxLkNoZWNrQ29tbWFuZENhcGFiaWxpdGllc1Jlc3BvbnNlQtMBChNjb20ubWluZXJjb21tYW5kLnYxQgxDb21tYW5kUHJvdG9QAVpRZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvbWluZXJjb21tYW5kL3YxO21pbmVyY29tbWFuZHYxogIDTVhYqgIPTWluZXJjb21tYW5kLlYxygIPTWluZXJjb21tYW5kXFYx4gIbTWluZXJjb21tYW5kXFYxXEdQQk1ldGFkYXRh6gIQTWluZXJjb21tYW5kOjpWMWIGcHJvdG8z", + [ + file_google_protobuf_timestamp, + file_fleetmanagement_v1_fleetmanagement, + file_common_v1_device_selector, + file_common_v1_cooling, + ], + ); + +/** + * @generated from message minercommand.v1.DeviceFilter + */ +export type DeviceFilter = Message<"minercommand.v1.DeviceFilter"> & { + /** + * @generated from field: repeated fleetmanagement.v1.DeviceStatus device_status = 1; + */ + deviceStatus: DeviceStatus[]; + + /** + * @generated from field: repeated fleetmanagement.v1.PairingStatus pairing_status = 2; + */ + pairingStatus: PairingStatus[]; + + /** + * @generated from field: repeated string models = 3; + */ + models: string[]; + + /** + * @generated from field: repeated string manufacturers = 4; + */ + manufacturers: string[]; +}; + +/** + * Describes the message minercommand.v1.DeviceFilter. + * Use `create(DeviceFilterSchema)` to create a new message. + */ +export const DeviceFilterSchema: GenMessage = /*@__PURE__*/ messageDesc(file_minercommand_v1_command, 0); + +/** + * @generated from message minercommand.v1.DeviceSelector + */ +export type DeviceSelector = Message<"minercommand.v1.DeviceSelector"> & { + /** + * @generated from oneof minercommand.v1.DeviceSelector.selection_type + */ + selectionType: + | { + /** + * @generated from field: minercommand.v1.DeviceFilter all_devices = 1; + */ + value: DeviceFilter; + case: "allDevices"; + } + | { + /** + * @generated from field: common.v1.DeviceIdentifierList include_devices = 2; + */ + value: DeviceIdentifierList; + case: "includeDevices"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message minercommand.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 1); + +/** + * @generated from message minercommand.v1.RebootRequest + */ +export type RebootRequest = Message<"minercommand.v1.RebootRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.RebootRequest. + * Use `create(RebootRequestSchema)` to create a new message. + */ +export const RebootRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 2); + +/** + * @generated from message minercommand.v1.RebootResponse + */ +export type RebootResponse = Message<"minercommand.v1.RebootResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.RebootResponse. + * Use `create(RebootResponseSchema)` to create a new message. + */ +export const RebootResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 3); + +/** + * Request to stop mining on specific miners + * + * @generated from message minercommand.v1.StopMiningRequest + */ +export type StopMiningRequest = Message<"minercommand.v1.StopMiningRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.StopMiningRequest. + * Use `create(StopMiningRequestSchema)` to create a new message. + */ +export const StopMiningRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 4); + +/** + * Response from stop mining request + * + * @generated from message minercommand.v1.StopMiningResponse + */ +export type StopMiningResponse = Message<"minercommand.v1.StopMiningResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.StopMiningResponse. + * Use `create(StopMiningResponseSchema)` to create a new message. + */ +export const StopMiningResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 5); + +/** + * Request to start mining on specific miners + * + * @generated from message minercommand.v1.StartMiningRequest + */ +export type StartMiningRequest = Message<"minercommand.v1.StartMiningRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.StartMiningRequest. + * Use `create(StartMiningRequestSchema)` to create a new message. + */ +export const StartMiningRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 6); + +/** + * Response from start mining request + * + * @generated from message minercommand.v1.StartMiningResponse + */ +export type StartMiningResponse = Message<"minercommand.v1.StartMiningResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.StartMiningResponse. + * Use `create(StartMiningResponseSchema)` to create a new message. + */ +export const StartMiningResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 7); + +/** + * @generated from message minercommand.v1.SetCoolingModeRequest + */ +export type SetCoolingModeRequest = Message<"minercommand.v1.SetCoolingModeRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * @generated from field: common.v1.CoolingMode mode = 2; + */ + mode: CoolingMode; +}; + +/** + * Describes the message minercommand.v1.SetCoolingModeRequest. + * Use `create(SetCoolingModeRequestSchema)` to create a new message. + */ +export const SetCoolingModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 8); + +/** + * @generated from message minercommand.v1.SetCoolingModeResponse + */ +export type SetCoolingModeResponse = Message<"minercommand.v1.SetCoolingModeResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.SetCoolingModeResponse. + * Use `create(SetCoolingModeResponseSchema)` to create a new message. + */ +export const SetCoolingModeResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 9); + +/** + * @generated from message minercommand.v1.SetPowerTargetRequest + */ +export type SetPowerTargetRequest = Message<"minercommand.v1.SetPowerTargetRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * @generated from field: minercommand.v1.PerformanceMode performance_mode = 2; + */ + performanceMode: PerformanceMode; +}; + +/** + * Describes the message minercommand.v1.SetPowerTargetRequest. + * Use `create(SetPowerTargetRequestSchema)` to create a new message. + */ +export const SetPowerTargetRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 10); + +/** + * @generated from message minercommand.v1.SetPowerTargetResponse + */ +export type SetPowerTargetResponse = Message<"minercommand.v1.SetPowerTargetResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.SetPowerTargetResponse. + * Use `create(SetPowerTargetResponseSchema)` to create a new message. + */ +export const SetPowerTargetResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 11); + +/** + * Configuration for a single pool slot + * Can specify either a Fleet pool ID (for known pools) or raw pool info (for unknown pools) + * + * @generated from message minercommand.v1.PoolSlotConfig + */ +export type PoolSlotConfig = Message<"minercommand.v1.PoolSlotConfig"> & { + /** + * @generated from oneof minercommand.v1.PoolSlotConfig.pool_source + */ + poolSource: + | { + /** + * Fleet pool ID - used when the pool exists in Fleet's database + * + * @generated from field: int64 pool_id = 1; + */ + value: bigint; + case: "poolId"; + } + | { + /** + * Raw pool info - used when the pool is configured on the miner but not in Fleet + * + * @generated from field: minercommand.v1.RawPoolInfo raw_pool = 2; + */ + value: RawPoolInfo; + case: "rawPool"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message minercommand.v1.PoolSlotConfig. + * Use `create(PoolSlotConfigSchema)` to create a new message. + */ +export const PoolSlotConfigSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 12); + +/** + * Raw pool configuration for pools not stored in Fleet + * + * @generated from message minercommand.v1.RawPoolInfo + */ +export type RawPoolInfo = Message<"minercommand.v1.RawPoolInfo"> & { + /** + * @generated from field: string url = 1; + */ + url: string; + + /** + * @generated from field: string username = 2; + */ + username: string; + + /** + * Password is optional since miners don't expose it + * + * @generated from field: optional string password = 3; + */ + password?: string; +}; + +/** + * Describes the message minercommand.v1.RawPoolInfo. + * Use `create(RawPoolInfoSchema)` to create a new message. + */ +export const RawPoolInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_minercommand_v1_command, 13); + +/** + * @generated from message minercommand.v1.UpdateMiningPoolsRequest + */ +export type UpdateMiningPoolsRequest = Message<"minercommand.v1.UpdateMiningPoolsRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Default pool (priority 0) - required + * + * @generated from field: minercommand.v1.PoolSlotConfig default_pool = 2; + */ + defaultPool?: PoolSlotConfig; + + /** + * Backup pool 1 (priority 1) - optional + * + * @generated from field: optional minercommand.v1.PoolSlotConfig backup_1_pool = 3; + */ + backup1Pool?: PoolSlotConfig; + + /** + * Backup pool 2 (priority 2) - optional + * + * @generated from field: optional minercommand.v1.PoolSlotConfig backup_2_pool = 4; + */ + backup2Pool?: PoolSlotConfig; + + /** + * Fleet user's username for authorization + * + * @generated from field: string user_username = 5; + */ + userUsername: string; + + /** + * Fleet user's password for authorization + * + * @generated from field: string user_password = 6; + */ + userPassword: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMiningPoolsRequest. + * Use `create(UpdateMiningPoolsRequestSchema)` to create a new message. + */ +export const UpdateMiningPoolsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 14); + +/** + * @generated from message minercommand.v1.UpdateMiningPoolsResponse + */ +export type UpdateMiningPoolsResponse = Message<"minercommand.v1.UpdateMiningPoolsResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMiningPoolsResponse. + * Use `create(UpdateMiningPoolsResponseSchema)` to create a new message. + */ +export const UpdateMiningPoolsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 15); + +/** + * @generated from message minercommand.v1.DownloadLogsRequest + */ +export type DownloadLogsRequest = Message<"minercommand.v1.DownloadLogsRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.DownloadLogsRequest. + * Use `create(DownloadLogsRequestSchema)` to create a new message. + */ +export const DownloadLogsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 16); + +/** + * @generated from message minercommand.v1.DownloadLogsResponse + */ +export type DownloadLogsResponse = Message<"minercommand.v1.DownloadLogsResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.DownloadLogsResponse. + * Use `create(DownloadLogsResponseSchema)` to create a new message. + */ +export const DownloadLogsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 17); + +/** + * @generated from message minercommand.v1.BlinkLEDRequest + */ +export type BlinkLEDRequest = Message<"minercommand.v1.BlinkLEDRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.BlinkLEDRequest. + * Use `create(BlinkLEDRequestSchema)` to create a new message. + */ +export const BlinkLEDRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 18); + +/** + * @generated from message minercommand.v1.BlinkLEDResponse + */ +export type BlinkLEDResponse = Message<"minercommand.v1.BlinkLEDResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.BlinkLEDResponse. + * Use `create(BlinkLEDResponseSchema)` to create a new message. + */ +export const BlinkLEDResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 19); + +/** + * @generated from message minercommand.v1.FirmwareUpdateRequest + */ +export type FirmwareUpdateRequest = Message<"minercommand.v1.FirmwareUpdateRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * Reference to a firmware file previously uploaded via the firmware upload HTTP endpoint + * + * @generated from field: string firmware_file_id = 2; + */ + firmwareFileId: string; +}; + +/** + * Describes the message minercommand.v1.FirmwareUpdateRequest. + * Use `create(FirmwareUpdateRequestSchema)` to create a new message. + */ +export const FirmwareUpdateRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 20); + +/** + * @generated from message minercommand.v1.FirmwareUpdateResponse + */ +export type FirmwareUpdateResponse = Message<"minercommand.v1.FirmwareUpdateResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.FirmwareUpdateResponse. + * Use `create(FirmwareUpdateResponseSchema)` to create a new message. + */ +export const FirmwareUpdateResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 21); + +/** + * @generated from message minercommand.v1.UnpairRequest + */ +export type UnpairRequest = Message<"minercommand.v1.UnpairRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.UnpairRequest. + * Use `create(UnpairRequestSchema)` to create a new message. + */ +export const UnpairRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 22); + +/** + * @generated from message minercommand.v1.UnpairResponse + */ +export type UnpairResponse = Message<"minercommand.v1.UnpairResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.UnpairResponse. + * Use `create(UnpairResponseSchema)` to create a new message. + */ +export const UnpairResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 23); + +/** + * Updates miner web UI password + * + * @generated from message minercommand.v1.UpdateMinerPasswordRequest + */ +export type UpdateMinerPasswordRequest = Message<"minercommand.v1.UpdateMinerPasswordRequest"> & { + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * New password for miner web UI access + * + * @generated from field: string new_password = 2; + */ + newPassword: string; + + /** + * Current password for verification (required by miner APIs) + * + * @generated from field: string current_password = 3; + */ + currentPassword: string; + + /** + * Fleet user's username for authorization + * + * @generated from field: string user_username = 4; + */ + userUsername: string; + + /** + * Fleet user's password for authorization + * + * @generated from field: string user_password = 5; + */ + userPassword: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMinerPasswordRequest. + * Use `create(UpdateMinerPasswordRequestSchema)` to create a new message. + */ +export const UpdateMinerPasswordRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 24); + +/** + * @generated from message minercommand.v1.UpdateMinerPasswordResponse + */ +export type UpdateMinerPasswordResponse = Message<"minercommand.v1.UpdateMinerPasswordResponse"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.UpdateMinerPasswordResponse. + * Use `create(UpdateMinerPasswordResponseSchema)` to create a new message. + */ +export const UpdateMinerPasswordResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 25); + +/** + * @generated from message minercommand.v1.StreamCommandBatchUpdatesRequest + */ +export type StreamCommandBatchUpdatesRequest = Message<"minercommand.v1.StreamCommandBatchUpdatesRequest"> & { + /** + * The identifier of the command batch to stream + * + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.StreamCommandBatchUpdatesRequest. + * Use `create(StreamCommandBatchUpdatesRequestSchema)` to create a new message. + */ +export const StreamCommandBatchUpdatesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 26); + +/** + * @generated from message minercommand.v1.StreamCommandBatchUpdatesResponse + */ +export type StreamCommandBatchUpdatesResponse = Message<"minercommand.v1.StreamCommandBatchUpdatesResponse"> & { + /** + * Timestamp when this update was generated + * + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Identifier of the command batch this update is for + * + * @generated from field: string command_batch_identifier = 2; + */ + commandBatchIdentifier: string; + + /** + * @generated from field: minercommand.v1.CommandBatchUpdateStatus status = 3; + */ + status?: CommandBatchUpdateStatus; +}; + +/** + * Describes the message minercommand.v1.StreamCommandBatchUpdatesResponse. + * Use `create(StreamCommandBatchUpdatesResponseSchema)` to create a new message. + */ +export const StreamCommandBatchUpdatesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 27); + +/** + * @generated from message minercommand.v1.CommandBatchUpdateDeviceCount + */ +export type CommandBatchUpdateDeviceCount = Message<"minercommand.v1.CommandBatchUpdateDeviceCount"> & { + /** + * @generated from field: int64 total = 1; + */ + total: bigint; + + /** + * @generated from field: int64 success = 2; + */ + success: bigint; + + /** + * @generated from field: int64 failure = 3; + */ + failure: bigint; + + /** + * @generated from field: repeated string success_device_identifiers = 4; + */ + successDeviceIdentifiers: string[]; + + /** + * @generated from field: repeated string failure_device_identifiers = 5; + */ + failureDeviceIdentifiers: string[]; +}; + +/** + * Describes the message minercommand.v1.CommandBatchUpdateDeviceCount. + * Use `create(CommandBatchUpdateDeviceCountSchema)` to create a new message. + */ +export const CommandBatchUpdateDeviceCountSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 28); + +/** + * @generated from message minercommand.v1.CommandBatchUpdateStatus + */ +export type CommandBatchUpdateStatus = Message<"minercommand.v1.CommandBatchUpdateStatus"> & { + /** + * @generated from field: minercommand.v1.CommandBatchUpdateStatus.CommandBatchUpdateStatusType command_batch_update_status = 1; + */ + commandBatchUpdateStatus: CommandBatchUpdateStatus_CommandBatchUpdateStatusType; + + /** + * @generated from field: minercommand.v1.CommandBatchUpdateDeviceCount command_batch_device_count = 2; + */ + commandBatchDeviceCount?: CommandBatchUpdateDeviceCount; +}; + +/** + * Describes the message minercommand.v1.CommandBatchUpdateStatus. + * Use `create(CommandBatchUpdateStatusSchema)` to create a new message. + */ +export const CommandBatchUpdateStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 29); + +/** + * @generated from enum minercommand.v1.CommandBatchUpdateStatus.CommandBatchUpdateStatusType + */ +export enum CommandBatchUpdateStatus_CommandBatchUpdateStatusType { + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_PENDING = 1; + */ + PENDING = 1, + + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_PROCESSING = 2; + */ + PROCESSING = 2, + + /** + * @generated from enum value: COMMAND_BATCH_UPDATE_STATUS_TYPE_FINISHED = 3; + */ + FINISHED = 3, +} + +/** + * Describes the enum minercommand.v1.CommandBatchUpdateStatus.CommandBatchUpdateStatusType. + */ +export const CommandBatchUpdateStatus_CommandBatchUpdateStatusTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_minercommand_v1_command, 29, 0); + +/** + * @generated from message minercommand.v1.GetCommandBatchLogBundleRequest + */ +export type GetCommandBatchLogBundleRequest = Message<"minercommand.v1.GetCommandBatchLogBundleRequest"> & { + /** + * @generated from field: string batch_identifier = 1; + */ + batchIdentifier: string; +}; + +/** + * Describes the message minercommand.v1.GetCommandBatchLogBundleRequest. + * Use `create(GetCommandBatchLogBundleRequestSchema)` to create a new message. + */ +export const GetCommandBatchLogBundleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 30); + +/** + * @generated from message minercommand.v1.GetCommandBatchLogBundleResponse + */ +export type GetCommandBatchLogBundleResponse = Message<"minercommand.v1.GetCommandBatchLogBundleResponse"> & { + /** + * @generated from field: bytes chunk_data = 1; + */ + chunkData: Uint8Array; + + /** + * @generated from field: string filename = 2; + */ + filename: string; +}; + +/** + * Describes the message minercommand.v1.GetCommandBatchLogBundleResponse. + * Use `create(GetCommandBatchLogBundleResponseSchema)` to create a new message. + */ +export const GetCommandBatchLogBundleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 31); + +/** + * Request to check command capabilities for selected devices + * + * @generated from message minercommand.v1.CheckCommandCapabilitiesRequest + */ +export type CheckCommandCapabilitiesRequest = Message<"minercommand.v1.CheckCommandCapabilitiesRequest"> & { + /** + * @generated from field: minercommand.v1.CommandType command_type = 1; + */ + commandType: CommandType; + + /** + * @generated from field: minercommand.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message minercommand.v1.CheckCommandCapabilitiesRequest. + * Use `create(CheckCommandCapabilitiesRequestSchema)` to create a new message. + */ +export const CheckCommandCapabilitiesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 32); + +/** + * Group of unsupported miners with same firmware and model + * + * @generated from message minercommand.v1.UnsupportedMinerGroup + */ +export type UnsupportedMinerGroup = Message<"minercommand.v1.UnsupportedMinerGroup"> & { + /** + * @generated from field: string firmware_version = 1; + */ + firmwareVersion: string; + + /** + * @generated from field: string model = 2; + */ + model: string; + + /** + * @generated from field: int32 count = 3; + */ + count: number; +}; + +/** + * Describes the message minercommand.v1.UnsupportedMinerGroup. + * Use `create(UnsupportedMinerGroupSchema)` to create a new message. + */ +export const UnsupportedMinerGroupSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 33); + +/** + * Response with capability check results + * + * @generated from message minercommand.v1.CheckCommandCapabilitiesResponse + */ +export type CheckCommandCapabilitiesResponse = Message<"minercommand.v1.CheckCommandCapabilitiesResponse"> & { + /** + * @generated from field: int32 supported_count = 1; + */ + supportedCount: number; + + /** + * @generated from field: int32 unsupported_count = 2; + */ + unsupportedCount: number; + + /** + * @generated from field: int32 total_count = 3; + */ + totalCount: number; + + /** + * @generated from field: bool all_supported = 4; + */ + allSupported: boolean; + + /** + * @generated from field: bool none_supported = 5; + */ + noneSupported: boolean; + + /** + * @generated from field: repeated minercommand.v1.UnsupportedMinerGroup unsupported_groups = 6; + */ + unsupportedGroups: UnsupportedMinerGroup[]; + + /** + * Device identifiers that support the command (for filtered execution) + * + * @generated from field: repeated string supported_device_identifiers = 7; + */ + supportedDeviceIdentifiers: string[]; +}; + +/** + * Describes the message minercommand.v1.CheckCommandCapabilitiesResponse. + * Use `create(CheckCommandCapabilitiesResponseSchema)` to create a new message. + */ +export const CheckCommandCapabilitiesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_minercommand_v1_command, 34); + +/** + * @generated from enum minercommand.v1.PerformanceMode + */ +export enum PerformanceMode { + /** + * @generated from enum value: PERFORMANCE_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: PERFORMANCE_MODE_MAXIMUM_HASHRATE = 1; + */ + MAXIMUM_HASHRATE = 1, + + /** + * @generated from enum value: PERFORMANCE_MODE_EFFICIENCY = 2; + */ + EFFICIENCY = 2, +} + +/** + * Describes the enum minercommand.v1.PerformanceMode. + */ +export const PerformanceModeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_minercommand_v1_command, 0); + +/** + * Command type enum for capability checking + * + * @generated from enum minercommand.v1.CommandType + */ +export enum CommandType { + /** + * @generated from enum value: COMMAND_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMMAND_TYPE_REBOOT = 1; + */ + REBOOT = 1, + + /** + * @generated from enum value: COMMAND_TYPE_START_MINING = 2; + */ + START_MINING = 2, + + /** + * @generated from enum value: COMMAND_TYPE_STOP_MINING = 3; + */ + STOP_MINING = 3, + + /** + * @generated from enum value: COMMAND_TYPE_BLINK_LED = 4; + */ + BLINK_LED = 4, + + /** + * @generated from enum value: COMMAND_TYPE_SET_COOLING_MODE = 5; + */ + SET_COOLING_MODE = 5, + + /** + * @generated from enum value: COMMAND_TYPE_UPDATE_MINING_POOLS = 6; + */ + UPDATE_MINING_POOLS = 6, + + /** + * @generated from enum value: COMMAND_TYPE_DOWNLOAD_LOGS = 7; + */ + DOWNLOAD_LOGS = 7, + + /** + * @generated from enum value: COMMAND_TYPE_FIRMWARE_UPDATE = 8; + */ + FIRMWARE_UPDATE = 8, + + /** + * @generated from enum value: COMMAND_TYPE_SET_POWER_TARGET = 9; + */ + SET_POWER_TARGET = 9, + + /** + * @generated from enum value: COMMAND_TYPE_UPDATE_MINER_PASSWORD = 10; + */ + UPDATE_MINER_PASSWORD = 10, +} + +/** + * Describes the enum minercommand.v1.CommandType. + */ +export const CommandTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_minercommand_v1_command, 1); + +/** + * Service for executing Miner commands + * + * @generated from service minercommand.v1.MinerCommandService + */ +export const MinerCommandService: GenService<{ + /** + * @generated from rpc minercommand.v1.MinerCommandService.Reboot + */ + reboot: { + methodKind: "unary"; + input: typeof RebootRequestSchema; + output: typeof RebootResponseSchema; + }; + /** + * Stops mining on specified miners + * The operation is attempted on all miners even if some fail + * + * @generated from rpc minercommand.v1.MinerCommandService.StopMining + */ + stopMining: { + methodKind: "unary"; + input: typeof StopMiningRequestSchema; + output: typeof StopMiningResponseSchema; + }; + /** + * Starts mining on specified miners + * The operation is attempted on all miners even if some fail + * + * @generated from rpc minercommand.v1.MinerCommandService.StartMining + */ + startMining: { + methodKind: "unary"; + input: typeof StartMiningRequestSchema; + output: typeof StartMiningResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.SetCoolingMode + */ + setCoolingMode: { + methodKind: "unary"; + input: typeof SetCoolingModeRequestSchema; + output: typeof SetCoolingModeResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.SetPowerTarget + */ + setPowerTarget: { + methodKind: "unary"; + input: typeof SetPowerTargetRequestSchema; + output: typeof SetPowerTargetResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.UpdateMiningPools + */ + updateMiningPools: { + methodKind: "unary"; + input: typeof UpdateMiningPoolsRequestSchema; + output: typeof UpdateMiningPoolsResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.DownloadLogs + */ + downloadLogs: { + methodKind: "unary"; + input: typeof DownloadLogsRequestSchema; + output: typeof DownloadLogsResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.BlinkLED + */ + blinkLED: { + methodKind: "unary"; + input: typeof BlinkLEDRequestSchema; + output: typeof BlinkLEDResponseSchema; + }; + /** + * Streams command batch updates + * + * @generated from rpc minercommand.v1.MinerCommandService.StreamCommandBatchUpdates + */ + streamCommandBatchUpdates: { + methodKind: "server_streaming"; + input: typeof StreamCommandBatchUpdatesRequestSchema; + output: typeof StreamCommandBatchUpdatesResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.GetCommandBatchLogBundle + */ + getCommandBatchLogBundle: { + methodKind: "unary"; + input: typeof GetCommandBatchLogBundleRequestSchema; + output: typeof GetCommandBatchLogBundleResponseSchema; + }; + /** + * @generated from rpc minercommand.v1.MinerCommandService.FirmwareUpdate + */ + firmwareUpdate: { + methodKind: "unary"; + input: typeof FirmwareUpdateRequestSchema; + output: typeof FirmwareUpdateResponseSchema; + }; + /** + * Unpairs devices from the fleet + * Updates pairing status to UNPAIRED and clears credentials on the device + * + * @generated from rpc minercommand.v1.MinerCommandService.Unpair + */ + unpair: { + methodKind: "unary"; + input: typeof UnpairRequestSchema; + output: typeof UnpairResponseSchema; + }; + /** + * Updates miner web UI password on specified miners + * The operation is attempted on all miners even if some fail + * + * @generated from rpc minercommand.v1.MinerCommandService.UpdateMinerPassword + */ + updateMinerPassword: { + methodKind: "unary"; + input: typeof UpdateMinerPasswordRequestSchema; + output: typeof UpdateMinerPasswordResponseSchema; + }; + /** + * Checks if selected devices support a command before execution + * Returns capability check results with unsupported miners grouped by model/firmware + * + * @generated from rpc minercommand.v1.MinerCommandService.CheckCommandCapabilities + */ + checkCommandCapabilities: { + methodKind: "unary"; + input: typeof CheckCommandCapabilitiesRequestSchema; + output: typeof CheckCommandCapabilitiesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_minercommand_v1_command, 0); diff --git a/client/src/protoFleet/api/generated/networkinfo/v1/networkinfo_pb.ts b/client/src/protoFleet/api/generated/networkinfo/v1/networkinfo_pb.ts new file mode 100644 index 000000000..661d634b9 --- /dev/null +++ b/client/src/protoFleet/api/generated/networkinfo/v1/networkinfo_pb.ts @@ -0,0 +1,175 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file networkinfo/v1/networkinfo.proto (package networkinfo.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file networkinfo/v1/networkinfo.proto. + */ +export const file_networkinfo_v1_networkinfo: GenFile = + /*@__PURE__*/ + fileDesc( + "CiBuZXR3b3JraW5mby92MS9uZXR3b3JraW5mby5wcm90bxIObmV0d29ya2luZm8udjEigwEKC05ldHdvcmtJbmZvEhgKEG5ldHdvcmtfbmlja25hbWUYASABKAkSEAoIbG9jYWxfaXAYAiABKAkSDwoHZ2F0ZXdheRgDIAEoCRIOCgZzdWJuZXQYBCABKAkSEgoKbG9jYWxfaXB2NhgFIAEoCRITCgtpcHY2X3N1Ym5ldBgGIAEoCSIXChVHZXROZXR3b3JrSW5mb1JlcXVlc3QiSwoWR2V0TmV0d29ya0luZm9SZXNwb25zZRIxCgxuZXR3b3JrX2luZm8YASABKAsyGy5uZXR3b3JraW5mby52MS5OZXR3b3JrSW5mbyI4ChxVcGRhdGVOZXR3b3JrTmlja25hbWVSZXF1ZXN0EhgKEG5ldHdvcmtfbmlja25hbWUYASABKAkiHwodVXBkYXRlTmV0d29ya05pY2tuYW1lUmVzcG9uc2Uy6wEKEk5ldHdvcmtJbmZvU2VydmljZRJfCg5HZXROZXR3b3JrSW5mbxIlLm5ldHdvcmtpbmZvLnYxLkdldE5ldHdvcmtJbmZvUmVxdWVzdBomLm5ldHdvcmtpbmZvLnYxLkdldE5ldHdvcmtJbmZvUmVzcG9uc2USdAoVVXBkYXRlTmV0d29ya05pY2tuYW1lEiwubmV0d29ya2luZm8udjEuVXBkYXRlTmV0d29ya05pY2tuYW1lUmVxdWVzdBotLm5ldHdvcmtpbmZvLnYxLlVwZGF0ZU5ldHdvcmtOaWNrbmFtZVJlc3BvbnNlQtABChJjb20ubmV0d29ya2luZm8udjFCEE5ldHdvcmtpbmZvUHJvdG9QAVpPZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvbmV0d29ya2luZm8vdjE7bmV0d29ya2luZm92MaICA05YWKoCDk5ldHdvcmtpbmZvLlYxygIOTmV0d29ya2luZm9cVjHiAhpOZXR3b3JraW5mb1xWMVxHUEJNZXRhZGF0YeoCD05ldHdvcmtpbmZvOjpWMWIGcHJvdG8z", + ); + +/** + * NetworkInfo represents the complete network configuration and identification details + * + * @generated from message networkinfo.v1.NetworkInfo + */ +export type NetworkInfo = Message<"networkinfo.v1.NetworkInfo"> & { + /** + * User-defined nickname for the network + * + * @generated from field: string network_nickname = 1; + */ + networkNickname: string; + + /** + * Local IP address assigned to this device on the network + * + * @generated from field: string local_ip = 2; + */ + localIp: string; + + /** + * Gateway IP address for the network + * + * @generated from field: string gateway = 3; + */ + gateway: string; + + /** + * Subnet mask or CIDR notation for the network + * + * @generated from field: string subnet = 4; + */ + subnet: string; + + /** + * Local IPv6 address assigned to this device on the network (empty if unavailable) + * + * @generated from field: string local_ipv6 = 5; + */ + localIpv6: string; + + /** + * IPv6 subnet in CIDR notation (empty if unavailable) + * + * @generated from field: string ipv6_subnet = 6; + */ + ipv6Subnet: string; +}; + +/** + * Describes the message networkinfo.v1.NetworkInfo. + * Use `create(NetworkInfoSchema)` to create a new message. + */ +export const NetworkInfoSchema: GenMessage = /*@__PURE__*/ messageDesc(file_networkinfo_v1_networkinfo, 0); + +/** + * Request to retrieve current network information + * Empty message as no parameters are needed + * + * @generated from message networkinfo.v1.GetNetworkInfoRequest + */ +export type GetNetworkInfoRequest = Message<"networkinfo.v1.GetNetworkInfoRequest"> & {}; + +/** + * Describes the message networkinfo.v1.GetNetworkInfoRequest. + * Use `create(GetNetworkInfoRequestSchema)` to create a new message. + */ +export const GetNetworkInfoRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 1); + +/** + * Response containing the current network information + * + * @generated from message networkinfo.v1.GetNetworkInfoResponse + */ +export type GetNetworkInfoResponse = Message<"networkinfo.v1.GetNetworkInfoResponse"> & { + /** + * Complete network information details + * + * @generated from field: networkinfo.v1.NetworkInfo network_info = 1; + */ + networkInfo?: NetworkInfo; +}; + +/** + * Describes the message networkinfo.v1.GetNetworkInfoResponse. + * Use `create(GetNetworkInfoResponseSchema)` to create a new message. + */ +export const GetNetworkInfoResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 2); + +/** + * Request to update the user-defined nickname for the network + * + * @generated from message networkinfo.v1.UpdateNetworkNicknameRequest + */ +export type UpdateNetworkNicknameRequest = Message<"networkinfo.v1.UpdateNetworkNicknameRequest"> & { + /** + * New nickname to assign to the network + * + * @generated from field: string network_nickname = 1; + */ + networkNickname: string; +}; + +/** + * Describes the message networkinfo.v1.UpdateNetworkNicknameRequest. + * Use `create(UpdateNetworkNicknameRequestSchema)` to create a new message. + */ +export const UpdateNetworkNicknameRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 3); + +/** + * Response to network nickname update request + * Empty message as no return data is needed + * + * @generated from message networkinfo.v1.UpdateNetworkNicknameResponse + */ +export type UpdateNetworkNicknameResponse = Message<"networkinfo.v1.UpdateNetworkNicknameResponse"> & {}; + +/** + * Describes the message networkinfo.v1.UpdateNetworkNicknameResponse. + * Use `create(UpdateNetworkNicknameResponseSchema)` to create a new message. + */ +export const UpdateNetworkNicknameResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_networkinfo_v1_networkinfo, 4); + +/** + * Service for managing and retrieving network information + * + * @generated from service networkinfo.v1.NetworkInfoService + */ +export const NetworkInfoService: GenService<{ + /** + * Retrieves the current network configuration and status + * + * @generated from rpc networkinfo.v1.NetworkInfoService.GetNetworkInfo + */ + getNetworkInfo: { + methodKind: "unary"; + input: typeof GetNetworkInfoRequestSchema; + output: typeof GetNetworkInfoResponseSchema; + }; + /** + * Updates the user-defined nickname for the current network + * + * @generated from rpc networkinfo.v1.NetworkInfoService.UpdateNetworkNickname + */ + updateNetworkNickname: { + methodKind: "unary"; + input: typeof UpdateNetworkNicknameRequestSchema; + output: typeof UpdateNetworkNicknameResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_networkinfo_v1_networkinfo, 0); diff --git a/client/src/protoFleet/api/generated/onboarding/v1/onboarding_pb.ts b/client/src/protoFleet/api/generated/onboarding/v1/onboarding_pb.ts new file mode 100644 index 000000000..c23990ae7 --- /dev/null +++ b/client/src/protoFleet/api/generated/onboarding/v1/onboarding_pb.ts @@ -0,0 +1,190 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file onboarding/v1/onboarding.proto (package onboarding.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file onboarding/v1/onboarding.proto. + */ +export const file_onboarding_v1_onboarding: GenFile = + /*@__PURE__*/ + fileDesc( + "Ch5vbmJvYXJkaW5nL3YxL29uYm9hcmRpbmcucHJvdG8SDW9uYm9hcmRpbmcudjEiPQoXQ3JlYXRlQWRtaW5Mb2dpblJlcXVlc3QSEAoIdXNlcm5hbWUYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiKwoYQ3JlYXRlQWRtaW5Mb2dpblJlc3BvbnNlEg8KB3VzZXJfaWQYASABKAkiGwoZR2V0RmxlZXRJbml0U3RhdHVzUmVxdWVzdCJMChpHZXRGbGVldEluaXRTdGF0dXNSZXNwb25zZRIuCgZzdGF0dXMYASABKAsyHi5vbmJvYXJkaW5nLnYxLkZsZWV0SW5pdFN0YXR1cyIoCg9GbGVldEluaXRTdGF0dXMSFQoNYWRtaW5fY3JlYXRlZBgBIAEoCCIhCh9HZXRGbGVldE9uYm9hcmRpbmdTdGF0dXNSZXF1ZXN0IlgKIEdldEZsZWV0T25ib2FyZGluZ1N0YXR1c1Jlc3BvbnNlEjQKBnN0YXR1cxgBIAEoCzIkLm9uYm9hcmRpbmcudjEuRmxlZXRPbmJvYXJkaW5nU3RhdHVzIkcKFUZsZWV0T25ib2FyZGluZ1N0YXR1cxIXCg9wb29sX2NvbmZpZ3VyZWQYASABKAgSFQoNZGV2aWNlX3BhaXJlZBgCIAEoCDLgAgoRT25ib2FyZGluZ1NlcnZpY2USYwoQQ3JlYXRlQWRtaW5Mb2dpbhImLm9uYm9hcmRpbmcudjEuQ3JlYXRlQWRtaW5Mb2dpblJlcXVlc3QaJy5vbmJvYXJkaW5nLnYxLkNyZWF0ZUFkbWluTG9naW5SZXNwb25zZRJpChJHZXRGbGVldEluaXRTdGF0dXMSKC5vbmJvYXJkaW5nLnYxLkdldEZsZWV0SW5pdFN0YXR1c1JlcXVlc3QaKS5vbmJvYXJkaW5nLnYxLkdldEZsZWV0SW5pdFN0YXR1c1Jlc3BvbnNlEnsKGEdldEZsZWV0T25ib2FyZGluZ1N0YXR1cxIuLm9uYm9hcmRpbmcudjEuR2V0RmxlZXRPbmJvYXJkaW5nU3RhdHVzUmVxdWVzdBovLm9uYm9hcmRpbmcudjEuR2V0RmxlZXRPbmJvYXJkaW5nU3RhdHVzUmVzcG9uc2VCyAEKEWNvbS5vbmJvYXJkaW5nLnYxQg9PbmJvYXJkaW5nUHJvdG9QAVpNZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvb25ib2FyZGluZy92MTtvbmJvYXJkaW5ndjGiAgNPWFiqAg1PbmJvYXJkaW5nLlYxygINT25ib2FyZGluZ1xWMeICGU9uYm9hcmRpbmdcVjFcR1BCTWV0YWRhdGHqAg5PbmJvYXJkaW5nOjpWMWIGcHJvdG8z", + ); + +/** + * @generated from message onboarding.v1.CreateAdminLoginRequest + */ +export type CreateAdminLoginRequest = Message<"onboarding.v1.CreateAdminLoginRequest"> & { + /** + * @generated from field: string username = 1; + */ + username: string; + + /** + * @generated from field: string password = 2; + */ + password: string; +}; + +/** + * Describes the message onboarding.v1.CreateAdminLoginRequest. + * Use `create(CreateAdminLoginRequestSchema)` to create a new message. + */ +export const CreateAdminLoginRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 0); + +/** + * @generated from message onboarding.v1.CreateAdminLoginResponse + */ +export type CreateAdminLoginResponse = Message<"onboarding.v1.CreateAdminLoginResponse"> & { + /** + * @generated from field: string user_id = 1; + */ + userId: string; +}; + +/** + * Describes the message onboarding.v1.CreateAdminLoginResponse. + * Use `create(CreateAdminLoginResponseSchema)` to create a new message. + */ +export const CreateAdminLoginResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 1); + +/** + * @generated from message onboarding.v1.GetFleetInitStatusRequest + */ +export type GetFleetInitStatusRequest = Message<"onboarding.v1.GetFleetInitStatusRequest"> & {}; + +/** + * Describes the message onboarding.v1.GetFleetInitStatusRequest. + * Use `create(GetFleetInitStatusRequestSchema)` to create a new message. + */ +export const GetFleetInitStatusRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 2); + +/** + * @generated from message onboarding.v1.GetFleetInitStatusResponse + */ +export type GetFleetInitStatusResponse = Message<"onboarding.v1.GetFleetInitStatusResponse"> & { + /** + * @generated from field: onboarding.v1.FleetInitStatus status = 1; + */ + status?: FleetInitStatus; +}; + +/** + * Describes the message onboarding.v1.GetFleetInitStatusResponse. + * Use `create(GetFleetInitStatusResponseSchema)` to create a new message. + */ +export const GetFleetInitStatusResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 3); + +/** + * @generated from message onboarding.v1.FleetInitStatus + */ +export type FleetInitStatus = Message<"onboarding.v1.FleetInitStatus"> & { + /** + * @generated from field: bool admin_created = 1; + */ + adminCreated: boolean; +}; + +/** + * Describes the message onboarding.v1.FleetInitStatus. + * Use `create(FleetInitStatusSchema)` to create a new message. + */ +export const FleetInitStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 4); + +/** + * @generated from message onboarding.v1.GetFleetOnboardingStatusRequest + */ +export type GetFleetOnboardingStatusRequest = Message<"onboarding.v1.GetFleetOnboardingStatusRequest"> & {}; + +/** + * Describes the message onboarding.v1.GetFleetOnboardingStatusRequest. + * Use `create(GetFleetOnboardingStatusRequestSchema)` to create a new message. + */ +export const GetFleetOnboardingStatusRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 5); + +/** + * @generated from message onboarding.v1.GetFleetOnboardingStatusResponse + */ +export type GetFleetOnboardingStatusResponse = Message<"onboarding.v1.GetFleetOnboardingStatusResponse"> & { + /** + * @generated from field: onboarding.v1.FleetOnboardingStatus status = 1; + */ + status?: FleetOnboardingStatus; +}; + +/** + * Describes the message onboarding.v1.GetFleetOnboardingStatusResponse. + * Use `create(GetFleetOnboardingStatusResponseSchema)` to create a new message. + */ +export const GetFleetOnboardingStatusResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 6); + +/** + * @generated from message onboarding.v1.FleetOnboardingStatus + */ +export type FleetOnboardingStatus = Message<"onboarding.v1.FleetOnboardingStatus"> & { + /** + * @generated from field: bool pool_configured = 1; + */ + poolConfigured: boolean; + + /** + * @generated from field: bool device_paired = 2; + */ + devicePaired: boolean; +}; + +/** + * Describes the message onboarding.v1.FleetOnboardingStatus. + * Use `create(FleetOnboardingStatusSchema)` to create a new message. + */ +export const FleetOnboardingStatusSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_onboarding_v1_onboarding, 7); + +/** + * @generated from service onboarding.v1.OnboardingService + */ +export const OnboardingService: GenService<{ + /** + * @generated from rpc onboarding.v1.OnboardingService.CreateAdminLogin + */ + createAdminLogin: { + methodKind: "unary"; + input: typeof CreateAdminLoginRequestSchema; + output: typeof CreateAdminLoginResponseSchema; + }; + /** + * @generated from rpc onboarding.v1.OnboardingService.GetFleetInitStatus + */ + getFleetInitStatus: { + methodKind: "unary"; + input: typeof GetFleetInitStatusRequestSchema; + output: typeof GetFleetInitStatusResponseSchema; + }; + /** + * @generated from rpc onboarding.v1.OnboardingService.GetFleetOnboardingStatus + */ + getFleetOnboardingStatus: { + methodKind: "unary"; + input: typeof GetFleetOnboardingStatusRequestSchema; + output: typeof GetFleetOnboardingStatusResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_onboarding_v1_onboarding, 0); diff --git a/client/src/protoFleet/api/generated/pairing/v1/pairing_pb.ts b/client/src/protoFleet/api/generated/pairing/v1/pairing_pb.ts new file mode 100644 index 000000000..69cd4afce --- /dev/null +++ b/client/src/protoFleet/api/generated/pairing/v1/pairing_pb.ts @@ -0,0 +1,433 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file pairing/v1/pairing.proto (package pairing.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { MinerCapabilities } from "../../capabilities/v1/capabilities_pb"; +import { file_capabilities_v1_capabilities } from "../../capabilities/v1/capabilities_pb"; +import type { DeviceSelector } from "../../minercommand/v1/command_pb"; +import { file_minercommand_v1_command } from "../../minercommand/v1/command_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file pairing/v1/pairing.proto. + */ +export const file_pairing_v1_pairing: GenFile = + /*@__PURE__*/ + fileDesc( + "ChhwYWlyaW5nL3YxL3BhaXJpbmcucHJvdG8SCnBhaXJpbmcudjEinwIKBkRldmljZRIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRISCgppcF9hZGRyZXNzGAIgASgJEgwKBHBvcnQYAyABKAkSEwoLbWFjX2FkZHJlc3MYBCABKAkSFQoNc2VyaWFsX251bWJlchgFIAEoCRINCgVtb2RlbBgGIAEoCRIUCgxtYW51ZmFjdHVyZXIYByABKAkSEgoKdXJsX3NjaGVtZRgIIAEoCRI4CgxjYXBhYmlsaXRpZXMYCiABKAsyIi5jYXBhYmlsaXRpZXMudjEuTWluZXJDYXBhYmlsaXRpZXMSGAoQZmlybXdhcmVfdmVyc2lvbhgLIAEoCRITCgtkcml2ZXJfbmFtZRgMIAEoCUoECAkQClIEdHlwZSJDCgtDcmVkZW50aWFscxIQCgh1c2VybmFtZRgBIAEoCRIVCghwYXNzd29yZBgCIAEoCUgAiAEBQgsKCV9wYXNzd29yZCJQCg9NRE5TTW9kZVJlcXVlc3QSFAoMc2VydmljZV90eXBlGAEgASgJEg4KBmRvbWFpbhgCIAEoCRIXCg90aW1lb3V0X3NlY29uZHMYAyABKAUiMAoPTm1hcE1vZGVSZXF1ZXN0Eg4KBnRhcmdldBgBIAEoCRINCgVwb3J0cxgCIAMoCSJFChJJUFJhbmdlTW9kZVJlcXVlc3QSEAoIc3RhcnRfaXAYASABKAkSDgoGZW5kX2lwGAIgASgJEg0KBXBvcnRzGAMgAygJIjgKEUlQTGlzdE1vZGVSZXF1ZXN0EhQKDGlwX2FkZHJlc3NlcxgBIAMoCRINCgVwb3J0cxgCIAMoCSLZAQoPRGlzY292ZXJSZXF1ZXN0EjAKB2lwX2xpc3QYASABKAsyHS5wYWlyaW5nLnYxLklQTGlzdE1vZGVSZXF1ZXN0SAASMgoIaXBfcmFuZ2UYAiABKAsyHi5wYWlyaW5nLnYxLklQUmFuZ2VNb2RlUmVxdWVzdEgAEisKBG1kbnMYAyABKAsyGy5wYWlyaW5nLnYxLk1ETlNNb2RlUmVxdWVzdEgAEisKBG5tYXAYBCABKAsyGy5wYWlyaW5nLnYxLk5tYXBNb2RlUmVxdWVzdEgAQgYKBG1vZGUiRgoQRGlzY292ZXJSZXNwb25zZRIjCgdkZXZpY2VzGAEgAygLMhIucGFpcmluZy52MS5EZXZpY2USDQoFZXJyb3IYAiABKAkidQoLUGFpclJlcXVlc3QSLAoLY3JlZGVudGlhbHMYASABKAsyFy5wYWlyaW5nLnYxLkNyZWRlbnRpYWxzEjgKD2RldmljZV9zZWxlY3RvchgCIAEoCzIfLm1pbmVyY29tbWFuZC52MS5EZXZpY2VTZWxlY3RvciIpCgxQYWlyUmVzcG9uc2USGQoRZmFpbGVkX2RldmljZV9pZHMYASADKAkylAEKDlBhaXJpbmdTZXJ2aWNlEkcKCERpc2NvdmVyEhsucGFpcmluZy52MS5EaXNjb3ZlclJlcXVlc3QaHC5wYWlyaW5nLnYxLkRpc2NvdmVyUmVzcG9uc2UwARI5CgRQYWlyEhcucGFpcmluZy52MS5QYWlyUmVxdWVzdBoYLnBhaXJpbmcudjEuUGFpclJlc3BvbnNlQrABCg5jb20ucGFpcmluZy52MUIMUGFpcmluZ1Byb3RvUAFaR2dpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL3BhaXJpbmcvdjE7cGFpcmluZ3YxogIDUFhYqgIKUGFpcmluZy5WMcoCClBhaXJpbmdcVjHiAhZQYWlyaW5nXFYxXEdQQk1ldGFkYXRh6gILUGFpcmluZzo6VjFiBnByb3RvMw", + [file_capabilities_v1_capabilities, file_minercommand_v1_command], + ); + +/** + * Device represents a discovered network device that can be paired with the system + * + * @generated from message pairing.v1.Device + */ +export type Device = Message<"pairing.v1.Device"> & { + /** + * unique identifier of the device + * + * @generated from field: string device_identifier = 1; + */ + deviceIdentifier: string; + + /** + * IP address of the device (IPv4 or IPv6) + * + * @generated from field: string ip_address = 2; + */ + ipAddress: string; + + /** + * Port number where the device's service is running + * + * @generated from field: string port = 3; + */ + port: string; + + /** + * MAC address of the device (format: XX:XX:XX:XX:XX:XX) + * + * @generated from field: string mac_address = 4; + */ + macAddress: string; + + /** + * Serial number of the control board of the unit + * + * @generated from field: string serial_number = 5; + */ + serialNumber: string; + + /** + * Model name/number of the device + * + * @generated from field: string model = 6; + */ + model: string; + + /** + * Name of the device manufacturer + * + * @generated from field: string manufacturer = 7; + */ + manufacturer: string; + + /** + * URL scheme of the device's web interface + * + * @generated from field: string url_scheme = 8; + */ + urlScheme: string; + + /** + * Capabilities of the device + * + * @generated from field: capabilities.v1.MinerCapabilities capabilities = 10; + */ + capabilities?: MinerCapabilities; + + /** + * Firmware version (available after discovery/pairing) + * + * @generated from field: string firmware_version = 11; + */ + firmwareVersion: string; + + /** + * Driver name identifies which plugin handles this device (e.g., "proto", "antminer"). + * Used for plugin routing; distinct from type which describes hardware identity. + * + * @generated from field: string driver_name = 12; + */ + driverName: string; +}; + +/** + * Describes the message pairing.v1.Device. + * Use `create(DeviceSchema)` to create a new message. + */ +export const DeviceSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 0); + +/** + * Represents login credentials used for device pairing + * + * @generated from message pairing.v1.Credentials + */ +export type Credentials = Message<"pairing.v1.Credentials"> & { + /** + * @generated from field: string username = 1; + */ + username: string; + + /** + * @generated from field: optional string password = 2; + */ + password?: string; +}; + +/** + * Describes the message pairing.v1.Credentials. + * Use `create(CredentialsSchema)` to create a new message. + */ +export const CredentialsSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 1); + +/** + * Configuration for mDNS-based device discovery + * + * @generated from message pairing.v1.MDNSModeRequest + */ +export type MDNSModeRequest = Message<"pairing.v1.MDNSModeRequest"> & { + /** + * Service type to discover (e.g., "_fleet._tcp") + * Format: _servicename._protocol + * + * @generated from field: string service_type = 1; + */ + serviceType: string; + + /** + * Domain to search in (typically "local") + * + * @generated from field: string domain = 2; + */ + domain: string; + + /** + * How long to search for devices, in seconds + * + * @generated from field: int32 timeout_seconds = 3; + */ + timeoutSeconds: number; +}; + +/** + * Describes the message pairing.v1.MDNSModeRequest. + * Use `create(MDNSModeRequestSchema)` to create a new message. + */ +export const MDNSModeRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 2); + +/** + * Configuration for Nmap-based network scanning discovery + * + * @generated from message pairing.v1.NmapModeRequest + */ +export type NmapModeRequest = Message<"pairing.v1.NmapModeRequest"> & { + /** + * Target specification for scan + * Can be: single IP (192.168.1.1), hostname (device.local), + * IPv4 subnet (192.168.1.0/24), or IP range (192.168.1.1-10). + * IPv6 subnet scanning is not supported; use mDNS or IP list for IPv6 devices. + * + * @generated from field: string target = 1; + */ + target: string; + + /** + * Optional ports to scan. When omitted, the server derives canonical scan ports + * from loaded plugin metadata. If provided, these ports fully override the + * server-derived defaults. + * + * @generated from field: repeated string ports = 2; + */ + ports: string[]; +}; + +/** + * Describes the message pairing.v1.NmapModeRequest. + * Use `create(NmapModeRequestSchema)` to create a new message. + */ +export const NmapModeRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 3); + +/** + * Configuration for IP range-based device discovery + * + * @generated from message pairing.v1.IPRangeModeRequest + */ +export type IPRangeModeRequest = Message<"pairing.v1.IPRangeModeRequest"> & { + /** + * Starting IP address of the range to scan + * + * @generated from field: string start_ip = 1; + */ + startIp: string; + + /** + * Ending IP address of the range to scan + * + * @generated from field: string end_ip = 2; + */ + endIp: string; + + /** + * Optional ports to check on each IP address. When omitted, the server derives + * canonical scan ports from loaded plugin metadata. If provided, these ports + * fully override the server-derived defaults. + * + * @generated from field: repeated string ports = 3; + */ + ports: string[]; +}; + +/** + * Describes the message pairing.v1.IPRangeModeRequest. + * Use `create(IPRangeModeRequestSchema)` to create a new message. + */ +export const IPRangeModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pairing_v1_pairing, 4); + +/** + * Configuration for discovering devices from a specific list of IP addresses + * + * @generated from message pairing.v1.IPListModeRequest + */ +export type IPListModeRequest = Message<"pairing.v1.IPListModeRequest"> & { + /** + * List of IP addresses (IPv4, IPv6, or hostnames) to check + * + * @generated from field: repeated string ip_addresses = 1; + */ + ipAddresses: string[]; + + /** + * Optional ports to check on each IP address. When omitted, the server derives + * canonical scan ports from loaded plugin metadata. If provided, these ports + * fully override the server-derived defaults. + * + * @generated from field: repeated string ports = 2; + */ + ports: string[]; +}; + +/** + * Describes the message pairing.v1.IPListModeRequest. + * Use `create(IPListModeRequestSchema)` to create a new message. + */ +export const IPListModeRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pairing_v1_pairing, 5); + +/** + * Request message for device discovery, supporting multiple discovery modes + * + * @generated from message pairing.v1.DiscoverRequest + */ +export type DiscoverRequest = Message<"pairing.v1.DiscoverRequest"> & { + /** + * Only one discovery mode can be active at a time + * + * @generated from oneof pairing.v1.DiscoverRequest.mode + */ + mode: + | { + /** + * Discover from list of IPs + * + * @generated from field: pairing.v1.IPListModeRequest ip_list = 1; + */ + value: IPListModeRequest; + case: "ipList"; + } + | { + /** + * Discover in IP range + * + * @generated from field: pairing.v1.IPRangeModeRequest ip_range = 2; + */ + value: IPRangeModeRequest; + case: "ipRange"; + } + | { + /** + * Discover using mDNS + * + * @generated from field: pairing.v1.MDNSModeRequest mdns = 3; + */ + value: MDNSModeRequest; + case: "mdns"; + } + | { + /** + * Discover using Nmap + * + * @generated from field: pairing.v1.NmapModeRequest nmap = 4; + */ + value: NmapModeRequest; + case: "nmap"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message pairing.v1.DiscoverRequest. + * Use `create(DiscoverRequestSchema)` to create a new message. + */ +export const DiscoverRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 6); + +/** + * Response message containing discovered devices or errors + * + * @generated from message pairing.v1.DiscoverResponse + */ +export type DiscoverResponse = Message<"pairing.v1.DiscoverResponse"> & { + /** + * List of devices discovered in this response + * + * @generated from field: repeated pairing.v1.Device devices = 1; + */ + devices: Device[]; + + /** + * Error message if discovery failed + * Empty if discovery was successful + * + * @generated from field: string error = 2; + */ + error: string; +}; + +/** + * Describes the message pairing.v1.DiscoverResponse. + * Use `create(DiscoverResponseSchema)` to create a new message. + */ +export const DiscoverResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pairing_v1_pairing, 7); + +/** + * Request to pair with discovered devices + * + * @generated from message pairing.v1.PairRequest + */ +export type PairRequest = Message<"pairing.v1.PairRequest"> & { + /** + * Credentials for device authentication + * + * @generated from field: pairing.v1.Credentials credentials = 1; + */ + credentials?: Credentials; + + /** + * Device selector specifies which devices to pair + * + * @generated from field: minercommand.v1.DeviceSelector device_selector = 2; + */ + deviceSelector?: DeviceSelector; +}; + +/** + * Describes the message pairing.v1.PairRequest. + * Use `create(PairRequestSchema)` to create a new message. + */ +export const PairRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 8); + +/** + * Response to pairing request + * Empty message as success/failure is indicated by gRPC status + * + * @generated from message pairing.v1.PairResponse + */ +export type PairResponse = Message<"pairing.v1.PairResponse"> & { + /** + * @generated from field: repeated string failed_device_ids = 1; + */ + failedDeviceIds: string[]; +}; + +/** + * Describes the message pairing.v1.PairResponse. + * Use `create(PairResponseSchema)` to create a new message. + */ +export const PairResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pairing_v1_pairing, 9); + +/** + * Service for discovering and pairing with network devices + * + * @generated from service pairing.v1.PairingService + */ +export const PairingService: GenService<{ + /** + * Discovers devices on the network using the specified discovery mode + * Streams results as devices are found + * + * @generated from rpc pairing.v1.PairingService.Discover + */ + discover: { + methodKind: "server_streaming"; + input: typeof DiscoverRequestSchema; + output: typeof DiscoverResponseSchema; + }; + /** + * Initiates pairing with one or more discovered devices + * + * @generated from rpc pairing.v1.PairingService.Pair + */ + pair: { + methodKind: "unary"; + input: typeof PairRequestSchema; + output: typeof PairResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_pairing_v1_pairing, 0); diff --git a/client/src/protoFleet/api/generated/ping/v1/ping_pb.ts b/client/src/protoFleet/api/generated/ping/v1/ping_pb.ts new file mode 100644 index 000000000..b91dff436 --- /dev/null +++ b/client/src/protoFleet/api/generated/ping/v1/ping_pb.ts @@ -0,0 +1,148 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file ping/v1/ping.proto (package ping.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file ping/v1/ping.proto. + */ +export const file_ping_v1_ping: GenFile = + /*@__PURE__*/ + fileDesc( + "ChJwaW5nL3YxL3BpbmcucHJvdG8SB3BpbmcudjEiGwoLUGluZ1JlcXVlc3QSDAoEdGV4dBgBIAEoCSIcCgxQaW5nUmVzcG9uc2USDAoEdGV4dBgBIAEoCSIbCgtFY2hvUmVxdWVzdBIMCgR0ZXh0GAEgASgJIhwKDEVjaG9SZXNwb25zZRIMCgR0ZXh0GAEgASgJIiEKEVBpbmdTdHJlYW1SZXF1ZXN0EgwKBHRleHQYASABKAkiIgoSUGluZ1N0cmVhbVJlc3BvbnNlEgwKBHRleHQYASABKAkyzgEKC1BpbmdTZXJ2aWNlEjgKBFBpbmcSFC5waW5nLnYxLlBpbmdSZXF1ZXN0GhUucGluZy52MS5QaW5nUmVzcG9uc2UiA5ACARI4CgRFY2hvEhQucGluZy52MS5FY2hvUmVxdWVzdBoVLnBpbmcudjEuRWNob1Jlc3BvbnNlIgOQAgISSwoKUGluZ1N0cmVhbRIaLnBpbmcudjEuUGluZ1N0cmVhbVJlcXVlc3QaGy5waW5nLnYxLlBpbmdTdHJlYW1SZXNwb25zZSIAKAEwAUKYAQoLY29tLnBpbmcudjFCCVBpbmdQcm90b1ABWkFnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9waW5nL3YxO3Bpbmd2MaICA1BYWKoCB1BpbmcuVjHKAgdQaW5nXFYx4gITUGluZ1xWMVxHUEJNZXRhZGF0YeoCCFBpbmc6OlYxYgZwcm90bzM", + ); + +/** + * @generated from message ping.v1.PingRequest + */ +export type PingRequest = Message<"ping.v1.PingRequest"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingRequest. + * Use `create(PingRequestSchema)` to create a new message. + */ +export const PingRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 0); + +/** + * @generated from message ping.v1.PingResponse + */ +export type PingResponse = Message<"ping.v1.PingResponse"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingResponse. + * Use `create(PingResponseSchema)` to create a new message. + */ +export const PingResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 1); + +/** + * @generated from message ping.v1.EchoRequest + */ +export type EchoRequest = Message<"ping.v1.EchoRequest"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.EchoRequest. + * Use `create(EchoRequestSchema)` to create a new message. + */ +export const EchoRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 2); + +/** + * @generated from message ping.v1.EchoResponse + */ +export type EchoResponse = Message<"ping.v1.EchoResponse"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.EchoResponse. + * Use `create(EchoResponseSchema)` to create a new message. + */ +export const EchoResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 3); + +/** + * @generated from message ping.v1.PingStreamRequest + */ +export type PingStreamRequest = Message<"ping.v1.PingStreamRequest"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingStreamRequest. + * Use `create(PingStreamRequestSchema)` to create a new message. + */ +export const PingStreamRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 4); + +/** + * @generated from message ping.v1.PingStreamResponse + */ +export type PingStreamResponse = Message<"ping.v1.PingStreamResponse"> & { + /** + * @generated from field: string text = 1; + */ + text: string; +}; + +/** + * Describes the message ping.v1.PingStreamResponse. + * Use `create(PingStreamResponseSchema)` to create a new message. + */ +export const PingStreamResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_ping_v1_ping, 5); + +/** + * @generated from service ping.v1.PingService + */ +export const PingService: GenService<{ + /** + * Ping is a unary RPC that returns the same text that was sent. + * + * @generated from rpc ping.v1.PingService.Ping + */ + ping: { + methodKind: "unary"; + input: typeof PingRequestSchema; + output: typeof PingResponseSchema; + }; + /** + * Echo is a unary RPC that returns the same text that was sent. + * + * @generated from rpc ping.v1.PingService.Echo + */ + echo: { + methodKind: "unary"; + input: typeof EchoRequestSchema; + output: typeof EchoResponseSchema; + }; + /** + * PingStream is a bidirectional stream of pings. + * + * @generated from rpc ping.v1.PingService.PingStream + */ + pingStream: { + methodKind: "bidi_streaming"; + input: typeof PingStreamRequestSchema; + output: typeof PingStreamResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_ping_v1_ping, 0); diff --git a/client/src/protoFleet/api/generated/pools/v1/pools_pb.ts b/client/src/protoFleet/api/generated/pools/v1/pools_pb.ts new file mode 100644 index 000000000..17df6a423 --- /dev/null +++ b/client/src/protoFleet/api/generated/pools/v1/pools_pb.ts @@ -0,0 +1,444 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file pools/v1/pools.proto (package pools.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Duration } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_duration, file_google_protobuf_wrappers } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file pools/v1/pools.proto. + */ +export const file_pools_v1_pools: GenFile = + /*@__PURE__*/ + fileDesc( + "ChRwb29scy92MS9wb29scy5wcm90bxIIcG9vbHMudjEibgoKUG9vbENvbmZpZxILCgN1cmwYASABKAkSEAoIdXNlcm5hbWUYAiABKAkSLgoIcGFzc3dvcmQYAyABKAsyHC5nb29nbGUucHJvdG9idWYuU3RyaW5nVmFsdWUSEQoJcG9vbF9uYW1lGAQgASgJIkkKBFBvb2wSDwoHcG9vbF9pZBgBIAEoAxILCgN1cmwYAiABKAkSEAoIdXNlcm5hbWUYAyABKAkSEQoJcG9vbF9uYW1lGAQgASgJIhIKEExpc3RQb29sc1JlcXVlc3QiMgoRTGlzdFBvb2xzUmVzcG9uc2USHQoFcG9vbHMYASADKAsyDi5wb29scy52MS5Qb29sIj4KEUNyZWF0ZVBvb2xSZXF1ZXN0EikKC3Bvb2xfY29uZmlnGAEgASgLMhQucG9vbHMudjEuUG9vbENvbmZpZyIyChJDcmVhdGVQb29sUmVzcG9uc2USHAoEcG9vbBgBIAEoCzIOLnBvb2xzLnYxLlBvb2wihgEKEVVwZGF0ZVBvb2xSZXF1ZXN0Eg8KB3Bvb2xfaWQYASABKAMSEQoJcG9vbF9uYW1lGAIgASgJEgsKA3VybBgDIAEoCRIQCgh1c2VybmFtZRgEIAEoCRIuCghwYXNzd29yZBgFIAEoCzIcLmdvb2dsZS5wcm90b2J1Zi5TdHJpbmdWYWx1ZSIyChJVcGRhdGVQb29sUmVzcG9uc2USHAoEcG9vbBgBIAEoCzIOLnBvb2xzLnYxLlBvb2wiJAoRRGVsZXRlUG9vbFJlcXVlc3QSDwoHcG9vbF9pZBgBIAEoAyIUChJEZWxldGVQb29sUmVzcG9uc2UiwwIKE1ZhbGlkYXRlUG9vbFJlcXVlc3QSpAEKA3VybBgBIAEoCUKWAbpIkgHIAQFyjAEQDDKHAV5zdHJhdHVtXCsodGNwfHNzbHx3cyk6XC9cLygoW2EtekEtWjAtOV1bYS16QS1aMC05Li1dKlthLXpBLVowLTldXC5bYS16QS1aXXsyLH0pfChcZHsxLDN9XC4pezN9XGR7MSwzfXxcWyhbMC05YS1mQS1GOl0rKVxdKSg6XGR7MSw1fSk/JBIYCgh1c2VybmFtZRgCIAEoCUIGukgDyAEBEi4KCHBhc3N3b3JkGAMgASgLMhwuZ29vZ2xlLnByb3RvYnVmLlN0cmluZ1ZhbHVlEjsKB3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CD7pIDKoBCSIDCOgCMgIIASIWChRWYWxpZGF0ZVBvb2xSZXNwb25zZSqjAQoUUG9vbENvbm5lY3Rpb25TdGF0dXMSJgoiUE9PTF9DT05ORUNUSU9OX1NUQVRVU19VTlNQRUNJRklFRBAAEh8KG1BPT0xfQ09OTkVDVElPTl9TVEFUVVNfSURMRRABEiEKHVBPT0xfQ09OTkVDVElPTl9TVEFUVVNfQUNUSVZFEAISHwobUE9PTF9DT05ORUNUSU9OX1NUQVRVU19ERUFEEAMy/gIKDFBvb2xzU2VydmljZRJECglMaXN0UG9vbHMSGi5wb29scy52MS5MaXN0UG9vbHNSZXF1ZXN0GhsucG9vbHMudjEuTGlzdFBvb2xzUmVzcG9uc2USRwoKQ3JlYXRlUG9vbBIbLnBvb2xzLnYxLkNyZWF0ZVBvb2xSZXF1ZXN0GhwucG9vbHMudjEuQ3JlYXRlUG9vbFJlc3BvbnNlEkcKClVwZGF0ZVBvb2wSGy5wb29scy52MS5VcGRhdGVQb29sUmVxdWVzdBocLnBvb2xzLnYxLlVwZGF0ZVBvb2xSZXNwb25zZRJHCgpEZWxldGVQb29sEhsucG9vbHMudjEuRGVsZXRlUG9vbFJlcXVlc3QaHC5wb29scy52MS5EZWxldGVQb29sUmVzcG9uc2USTQoMVmFsaWRhdGVQb29sEh0ucG9vbHMudjEuVmFsaWRhdGVQb29sUmVxdWVzdBoeLnBvb2xzLnYxLlZhbGlkYXRlUG9vbFJlc3BvbnNlQqABCgxjb20ucG9vbHMudjFCClBvb2xzUHJvdG9QAVpDZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvcG9vbHMvdjE7cG9vbHN2MaICA1BYWKoCCFBvb2xzLlYxygIIUG9vbHNcVjHiAhRQb29sc1xWMVxHUEJNZXRhZGF0YeoCCVBvb2xzOjpWMWIGcHJvdG8z", + [file_google_protobuf_duration, file_google_protobuf_wrappers, file_buf_validate_validate], + ); + +/** + * PoolConfig defines the connection details for a mining pool + * + * @generated from message pools.v1.PoolConfig + */ +export type PoolConfig = Message<"pools.v1.PoolConfig"> & { + /** + * Pool's stratum URL (e.g., "stratum+tcp://pool.example.com:3333") + * Required field that specifies the endpoint for connecting to the pool + * + * @generated from field: string url = 1; + */ + url: string; + + /** + * Username or wallet address for pool authentication + * Required field that identifies the user/wallet receiving mining rewards + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Password for pool authentication + * May be optional depending on pool requirements, often used for worker identification + * + * @generated from field: google.protobuf.StringValue password = 3; + */ + password?: string; + + /** + * Pool name to identify this pool + * Human-readable identifier for the pool within the fleet management system + * + * @generated from field: string pool_name = 4; + */ + poolName: string; +}; + +/** + * Describes the message pools.v1.PoolConfig. + * Use `create(PoolConfigSchema)` to create a new message. + */ +export const PoolConfigSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 0); + +/** + * Pool defines a configured mining pool with its connection details and status + * + * @generated from message pools.v1.Pool + */ +export type Pool = Message<"pools.v1.Pool"> & { + /** + * Unique identifier for the pool within the system + * + * @generated from field: int64 pool_id = 1; + */ + poolId: bigint; + + /** + * Pool's stratum URL (e.g., "stratum+tcp://pool.example.com:3333") + * Endpoint for connecting to the pool + * + * @generated from field: string url = 2; + */ + url: string; + + /** + * Username or wallet address for pool authentication + * Identifies the user/wallet receiving mining rewards + * + * @generated from field: string username = 3; + */ + username: string; + + /** + * Pool name to identify this pool + * Human-readable identifier for the pool within the fleet management system + * + * @generated from field: string pool_name = 4; + */ + poolName: string; +}; + +/** + * Describes the message pools.v1.Pool. + * Use `create(PoolSchema)` to create a new message. + */ +export const PoolSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 1); + +/** + * Request to retrieve all configured mining pools + * + * Empty request as all pools are returned + * + * @generated from message pools.v1.ListPoolsRequest + */ +export type ListPoolsRequest = Message<"pools.v1.ListPoolsRequest"> & {}; + +/** + * Describes the message pools.v1.ListPoolsRequest. + * Use `create(ListPoolsRequestSchema)` to create a new message. + */ +export const ListPoolsRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 2); + +/** + * Response containing all configured mining pools + * + * @generated from message pools.v1.ListPoolsResponse + */ +export type ListPoolsResponse = Message<"pools.v1.ListPoolsResponse"> & { + /** + * List of all configured pools, ordered by priority + * + * @generated from field: repeated pools.v1.Pool pools = 1; + */ + pools: Pool[]; +}; + +/** + * Describes the message pools.v1.ListPoolsResponse. + * Use `create(ListPoolsResponseSchema)` to create a new message. + */ +export const ListPoolsResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 3); + +/** + * Request to create a new mining pool configuration + * + * @generated from message pools.v1.CreatePoolRequest + */ +export type CreatePoolRequest = Message<"pools.v1.CreatePoolRequest"> & { + /** + * Pool configuration details for the new pool + * Must contain all required connection information + * + * @generated from field: pools.v1.PoolConfig pool_config = 1; + */ + poolConfig?: PoolConfig; +}; + +/** + * Describes the message pools.v1.CreatePoolRequest. + * Use `create(CreatePoolRequestSchema)` to create a new message. + */ +export const CreatePoolRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 4); + +/** + * Response after creating a new mining pool + * + * @generated from message pools.v1.CreatePoolResponse + */ +export type CreatePoolResponse = Message<"pools.v1.CreatePoolResponse"> & { + /** + * The newly created pool with system-assigned ID and default values + * + * @generated from field: pools.v1.Pool pool = 1; + */ + pool?: Pool; +}; + +/** + * Describes the message pools.v1.CreatePoolResponse. + * Use `create(CreatePoolResponseSchema)` to create a new message. + */ +export const CreatePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 5); + +/** + * Request to update an existing pool's configuration + * + * @generated from message pools.v1.UpdatePoolRequest + */ +export type UpdatePoolRequest = Message<"pools.v1.UpdatePoolRequest"> & { + /** + * Unique identifier of the pool to update + * + * @generated from field: int64 pool_id = 1; + */ + poolId: bigint; + + /** + * New pool name (optional, leave empty to keep current value) + * + * @generated from field: string pool_name = 2; + */ + poolName: string; + + /** + * New pool URL (optional, leave empty to keep current value) + * + * @generated from field: string url = 3; + */ + url: string; + + /** + * New username (optional, leave empty to keep current value) + * + * @generated from field: string username = 4; + */ + username: string; + + /** + * New password (optional, leave empty to keep current value) + * + * @generated from field: google.protobuf.StringValue password = 5; + */ + password?: string; +}; + +/** + * Describes the message pools.v1.UpdatePoolRequest. + * Use `create(UpdatePoolRequestSchema)` to create a new message. + */ +export const UpdatePoolRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 6); + +/** + * Response after updating a pool's configuration + * + * @generated from message pools.v1.UpdatePoolResponse + */ +export type UpdatePoolResponse = Message<"pools.v1.UpdatePoolResponse"> & { + /** + * The updated pool with all current values + * + * @generated from field: pools.v1.Pool pool = 1; + */ + pool?: Pool; +}; + +/** + * Describes the message pools.v1.UpdatePoolResponse. + * Use `create(UpdatePoolResponseSchema)` to create a new message. + */ +export const UpdatePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 7); + +/** + * Request to delete a mining pool configuration + * + * @generated from message pools.v1.DeletePoolRequest + */ +export type DeletePoolRequest = Message<"pools.v1.DeletePoolRequest"> & { + /** + * Unique identifier of the pool to delete + * + * @generated from field: int64 pool_id = 1; + */ + poolId: bigint; +}; + +/** + * Describes the message pools.v1.DeletePoolRequest. + * Use `create(DeletePoolRequestSchema)` to create a new message. + */ +export const DeletePoolRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_pools_v1_pools, 8); + +/** + * Response after deleting a pool configuration + * + * Empty response as success/failure is indicated by gRPC status + * + * @generated from message pools.v1.DeletePoolResponse + */ +export type DeletePoolResponse = Message<"pools.v1.DeletePoolResponse"> & {}; + +/** + * Describes the message pools.v1.DeletePoolResponse. + * Use `create(DeletePoolResponseSchema)` to create a new message. + */ +export const DeletePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 9); + +/** + * Request to validate a pool's connection details + * + * @generated from message pools.v1.ValidatePoolRequest + */ +export type ValidatePoolRequest = Message<"pools.v1.ValidatePoolRequest"> & { + /** + * Pool's stratum URL (e.g., "stratum+tcp://pool.example.com:3333") + * Required field that specifies the endpoint for connecting to the pool + * + * @generated from field: string url = 1; + */ + url: string; + + /** + * Username or wallet address for pool authentication + * Required field that identifies the user/wallet receiving mining rewards + * + * @generated from field: string username = 2; + */ + username: string; + + /** + * Password for pool authentication + * May be optional depending on pool requirements, often used for worker identification + * + * @generated from field: google.protobuf.StringValue password = 3; + */ + password?: string; + + /** + * Set the timeout duration for validation + * This is an optional field for integration points that have issues setting context timeouts. + * + * @generated from field: google.protobuf.Duration timeout = 4; + */ + timeout?: Duration; +}; + +/** + * Describes the message pools.v1.ValidatePoolRequest. + * Use `create(ValidatePoolRequestSchema)` to create a new message. + */ +export const ValidatePoolRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 10); + +/** + * Response after validating a pool's connection details + * + * Empty response as success/failure is indicated by gRPC status + * + * @generated from message pools.v1.ValidatePoolResponse + */ +export type ValidatePoolResponse = Message<"pools.v1.ValidatePoolResponse"> & {}; + +/** + * Describes the message pools.v1.ValidatePoolResponse. + * Use `create(ValidatePoolResponseSchema)` to create a new message. + */ +export const ValidatePoolResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_pools_v1_pools, 11); + +/** + * @generated from enum pools.v1.PoolConnectionStatus + */ +export enum PoolConnectionStatus { + /** + * @generated from enum value: POOL_CONNECTION_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: POOL_CONNECTION_STATUS_IDLE = 1; + */ + IDLE = 1, + + /** + * @generated from enum value: POOL_CONNECTION_STATUS_ACTIVE = 2; + */ + ACTIVE = 2, + + /** + * @generated from enum value: POOL_CONNECTION_STATUS_DEAD = 3; + */ + DEAD = 3, +} + +/** + * Describes the enum pools.v1.PoolConnectionStatus. + */ +export const PoolConnectionStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_pools_v1_pools, 0); + +/** + * @generated from service pools.v1.PoolsService + */ +export const PoolsService: GenService<{ + /** + * Lists all configured mining pools + * + * @generated from rpc pools.v1.PoolsService.ListPools + */ + listPools: { + methodKind: "unary"; + input: typeof ListPoolsRequestSchema; + output: typeof ListPoolsResponseSchema; + }; + /** + * Creates a new mining pool configuration + * + * @generated from rpc pools.v1.PoolsService.CreatePool + */ + createPool: { + methodKind: "unary"; + input: typeof CreatePoolRequestSchema; + output: typeof CreatePoolResponseSchema; + }; + /** + * Updates an existing pool's configuration + * + * @generated from rpc pools.v1.PoolsService.UpdatePool + */ + updatePool: { + methodKind: "unary"; + input: typeof UpdatePoolRequestSchema; + output: typeof UpdatePoolResponseSchema; + }; + /** + * Deletes a pool configuration + * + * @generated from rpc pools.v1.PoolsService.DeletePool + */ + deletePool: { + methodKind: "unary"; + input: typeof DeletePoolRequestSchema; + output: typeof DeletePoolResponseSchema; + }; + /** + * Validates a pool's connection details + * + * @generated from rpc pools.v1.PoolsService.ValidatePool + */ + validatePool: { + methodKind: "unary"; + input: typeof ValidatePoolRequestSchema; + output: typeof ValidatePoolResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_pools_v1_pools, 0); diff --git a/client/src/protoFleet/api/generated/schedule/v1/schedule_pb.ts b/client/src/protoFleet/api/generated/schedule/v1/schedule_pb.ts new file mode 100644 index 000000000..5c693a402 --- /dev/null +++ b/client/src/protoFleet/api/generated/schedule/v1/schedule_pb.ts @@ -0,0 +1,942 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file schedule/v1/schedule.proto (package schedule.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file schedule/v1/schedule.proto. + */ +export const file_schedule_v1_schedule: GenFile = + /*@__PURE__*/ + fileDesc( + "ChpzY2hlZHVsZS92MS9zY2hlZHVsZS5wcm90bxILc2NoZWR1bGUudjEiPwoRUG93ZXJUYXJnZXRDb25maWcSKgoEbW9kZRgBIAEoDjIcLnNjaGVkdWxlLnYxLlBvd2VyVGFyZ2V0TW9kZSLJAQoSU2NoZWR1bGVSZWN1cnJlbmNlEjMKCWZyZXF1ZW5jeRgBIAEoDjIgLnNjaGVkdWxlLnYxLlJlY3VycmVuY2VGcmVxdWVuY3kSGQoIaW50ZXJ2YWwYAiABKAVCB7pIBBoCCAESLAoMZGF5c19vZl93ZWVrGAMgAygOMhYuc2NoZWR1bGUudjEuRGF5T2ZXZWVrEiQKDGRheV9vZl9tb250aBgEIAEoBUIJukgGGgQYHygBSACIAQFCDwoNX2RheV9vZl9tb250aCJuCg5TY2hlZHVsZVRhcmdldBJACgt0YXJnZXRfdHlwZRgBIAEoDjIfLnNjaGVkdWxlLnYxLlNjaGVkdWxlVGFyZ2V0VHlwZUIKukgHggEEEAEgABIaCgl0YXJnZXRfaWQYAiABKAlCB7pIBHICEAEixQUKCFNjaGVkdWxlEgoKAmlkGAEgASgDEgwKBG5hbWUYAiABKAkSKwoGYWN0aW9uGAMgASgOMhsuc2NoZWR1bGUudjEuU2NoZWR1bGVBY3Rpb24SNQoNYWN0aW9uX2NvbmZpZxgEIAEoCzIeLnNjaGVkdWxlLnYxLlBvd2VyVGFyZ2V0Q29uZmlnEjAKDXNjaGVkdWxlX3R5cGUYBSABKA4yGS5zY2hlZHVsZS52MS5TY2hlZHVsZVR5cGUSMwoKcmVjdXJyZW5jZRgGIAEoCzIfLnNjaGVkdWxlLnYxLlNjaGVkdWxlUmVjdXJyZW5jZRISCgpzdGFydF9kYXRlGAcgASgJEhIKCnN0YXJ0X3RpbWUYCCABKAkSEAoIZW5kX3RpbWUYCSABKAkSEAoIZW5kX2RhdGUYCyABKAkSEAoIdGltZXpvbmUYDSABKAkSKwoGc3RhdHVzGA4gASgOMhsuc2NoZWR1bGUudjEuU2NoZWR1bGVTdGF0dXMSEAoIcHJpb3JpdHkYDyABKAUSEgoKY3JlYXRlZF9ieRgRIAEoAxIuCgpjcmVhdGVkX2F0GBIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GBMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtsYXN0X3J1bl9hdBgUIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLbmV4dF9ydW5fYXQYFSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEiwKB3RhcmdldHMYFyADKAsyGy5zY2hlZHVsZS52MS5TY2hlZHVsZVRhcmdldBIbChNjcmVhdGVkX2J5X3VzZXJuYW1lGBggASgJSgQIChALSgQIDBANSgQIEBARSgQIFhAXIoQBChRMaXN0U2NoZWR1bGVzUmVxdWVzdBI1CgZzdGF0dXMYASABKA4yGy5zY2hlZHVsZS52MS5TY2hlZHVsZVN0YXR1c0IIukgFggECEAESNQoGYWN0aW9uGAIgASgOMhsuc2NoZWR1bGUudjEuU2NoZWR1bGVBY3Rpb25CCLpIBYIBAhABIkEKFUxpc3RTY2hlZHVsZXNSZXNwb25zZRIoCglzY2hlZHVsZXMYASADKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSLaBAoVQ3JlYXRlU2NoZWR1bGVSZXF1ZXN0EhoKBG5hbWUYASABKAlCDLpICcgBAXIEEAEYZBI3CgZhY3Rpb24YAiABKA4yGy5zY2hlZHVsZS52MS5TY2hlZHVsZUFjdGlvbkIKukgHggEEEAEgABI1Cg1hY3Rpb25fY29uZmlnGAMgASgLMh4uc2NoZWR1bGUudjEuUG93ZXJUYXJnZXRDb25maWcSPAoNc2NoZWR1bGVfdHlwZRgEIAEoDjIZLnNjaGVkdWxlLnYxLlNjaGVkdWxlVHlwZUIKukgHggEEEAEgABIzCgpyZWN1cnJlbmNlGAUgASgLMh8uc2NoZWR1bGUudjEuU2NoZWR1bGVSZWN1cnJlbmNlEj0KCnN0YXJ0X2RhdGUYBiABKAlCKbpIJsgBAXIhMhxeWzAtOV17NH0tWzAtOV17Mn0tWzAtOV17Mn0kmAEKEjQKCnN0YXJ0X3RpbWUYByABKAlCILpIHcgBAXIYMhNeWzAtOV17Mn06WzAtOV17Mn0kmAEFEjIKCGVuZF90aW1lGAggASgJQiC6SB3YAQFyGDITXlswLTldezJ9OlswLTldezJ9JJgBBRI7CghlbmRfZGF0ZRgKIAEoCUIpukgm2AEBciEyHF5bMC05XXs0fS1bMC05XXsyfS1bMC05XXsyfSSYAQoSHAoIdGltZXpvbmUYDCABKAlCCrpIB8gBAXICEAESLAoHdGFyZ2V0cxgOIAMoCzIbLnNjaGVkdWxlLnYxLlNjaGVkdWxlVGFyZ2V0SgQICRAKSgQICxAMSgQIDRAOIkEKFkNyZWF0ZVNjaGVkdWxlUmVzcG9uc2USJwoIc2NoZWR1bGUYASABKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSL4BAoVVXBkYXRlU2NoZWR1bGVSZXF1ZXN0EhwKC3NjaGVkdWxlX2lkGAEgASgDQge6SAQiAiAAEhoKBG5hbWUYAiABKAlCDLpICcgBAXIEEAEYZBI3CgZhY3Rpb24YAyABKA4yGy5zY2hlZHVsZS52MS5TY2hlZHVsZUFjdGlvbkIKukgHggEEEAEgABI1Cg1hY3Rpb25fY29uZmlnGAQgASgLMh4uc2NoZWR1bGUudjEuUG93ZXJUYXJnZXRDb25maWcSPAoNc2NoZWR1bGVfdHlwZRgFIAEoDjIZLnNjaGVkdWxlLnYxLlNjaGVkdWxlVHlwZUIKukgHggEEEAEgABIzCgpyZWN1cnJlbmNlGAYgASgLMh8uc2NoZWR1bGUudjEuU2NoZWR1bGVSZWN1cnJlbmNlEj0KCnN0YXJ0X2RhdGUYByABKAlCKbpIJsgBAXIhMhxeWzAtOV17NH0tWzAtOV17Mn0tWzAtOV17Mn0kmAEKEjQKCnN0YXJ0X3RpbWUYCCABKAlCILpIHcgBAXIYMhNeWzAtOV17Mn06WzAtOV17Mn0kmAEFEjIKCGVuZF90aW1lGAkgASgJQiC6SB3YAQFyGDITXlswLTldezJ9OlswLTldezJ9JJgBBRI7CghlbmRfZGF0ZRgLIAEoCUIpukgm2AEBciEyHF5bMC05XXs0fS1bMC05XXsyfS1bMC05XXsyfSSYAQoSHAoIdGltZXpvbmUYDSABKAlCCrpIB8gBAXICEAESLAoHdGFyZ2V0cxgPIAMoCzIbLnNjaGVkdWxlLnYxLlNjaGVkdWxlVGFyZ2V0SgQIChALSgQIDBANSgQIDhAPIkEKFlVwZGF0ZVNjaGVkdWxlUmVzcG9uc2USJwoIc2NoZWR1bGUYASABKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSI1ChVEZWxldGVTY2hlZHVsZVJlcXVlc3QSHAoLc2NoZWR1bGVfaWQYASABKANCB7pIBCICIAAiGAoWRGVsZXRlU2NoZWR1bGVSZXNwb25zZSI0ChRQYXVzZVNjaGVkdWxlUmVxdWVzdBIcCgtzY2hlZHVsZV9pZBgBIAEoA0IHukgEIgIgACJAChVQYXVzZVNjaGVkdWxlUmVzcG9uc2USJwoIc2NoZWR1bGUYASABKAsyFS5zY2hlZHVsZS52MS5TY2hlZHVsZSI1ChVSZXN1bWVTY2hlZHVsZVJlcXVlc3QSHAoLc2NoZWR1bGVfaWQYASABKANCB7pIBCICIAAiQQoWUmVzdW1lU2NoZWR1bGVSZXNwb25zZRInCghzY2hlZHVsZRgBIAEoCzIVLnNjaGVkdWxlLnYxLlNjaGVkdWxlIkEKF1Jlb3JkZXJTY2hlZHVsZXNSZXF1ZXN0EiYKDHNjaGVkdWxlX2lkcxgBIAMoA0IQukgNkgEKCAEYASIEIgIgACIaChhSZW9yZGVyU2NoZWR1bGVzUmVzcG9uc2UqpQEKDlNjaGVkdWxlU3RhdHVzEh8KG1NDSEVEVUxFX1NUQVRVU19VTlNQRUNJRklFRBAAEhoKFlNDSEVEVUxFX1NUQVRVU19BQ1RJVkUQARIaChZTQ0hFRFVMRV9TVEFUVVNfUEFVU0VEEAISGwoXU0NIRURVTEVfU1RBVFVTX1JVTk5JTkcQAxIdChlTQ0hFRFVMRV9TVEFUVVNfQ09NUExFVEVEEAQqjgEKDlNjaGVkdWxlQWN0aW9uEh8KG1NDSEVEVUxFX0FDVElPTl9VTlNQRUNJRklFRBAAEiQKIFNDSEVEVUxFX0FDVElPTl9TRVRfUE9XRVJfVEFSR0VUEAESGgoWU0NIRURVTEVfQUNUSU9OX1JFQk9PVBACEhkKFVNDSEVEVUxFX0FDVElPTl9TTEVFUBADKmYKDFNjaGVkdWxlVHlwZRIdChlTQ0hFRFVMRV9UWVBFX1VOU1BFQ0lGSUVEEAASGgoWU0NIRURVTEVfVFlQRV9PTkVfVElNRRABEhsKF1NDSEVEVUxFX1RZUEVfUkVDVVJSSU5HEAIqngEKE1JlY3VycmVuY2VGcmVxdWVuY3kSJAogUkVDVVJSRU5DRV9GUkVRVUVOQ1lfVU5TUEVDSUZJRUQQABIeChpSRUNVUlJFTkNFX0ZSRVFVRU5DWV9EQUlMWRABEh8KG1JFQ1VSUkVOQ0VfRlJFUVVFTkNZX1dFRUtMWRACEiAKHFJFQ1VSUkVOQ0VfRlJFUVVFTkNZX01PTlRITFkQAypuCg9Qb3dlclRhcmdldE1vZGUSIQodUE9XRVJfVEFSR0VUX01PREVfVU5TUEVDSUZJRUQQABIdChlQT1dFUl9UQVJHRVRfTU9ERV9ERUZBVUxUEAESGQoVUE9XRVJfVEFSR0VUX01PREVfTUFYEAIq2AEKCURheU9mV2VlaxIbChdEQVlfT0ZfV0VFS19VTlNQRUNJRklFRBAAEhYKEkRBWV9PRl9XRUVLX1NVTkRBWRABEhYKEkRBWV9PRl9XRUVLX01PTkRBWRACEhcKE0RBWV9PRl9XRUVLX1RVRVNEQVkQAxIZChVEQVlfT0ZfV0VFS19XRURORVNEQVkQBBIYChREQVlfT0ZfV0VFS19USFVSU0RBWRAFEhYKEkRBWV9PRl9XRUVLX0ZSSURBWRAGEhgKFERBWV9PRl9XRUVLX1NBVFVSREFZEAcqmQEKElNjaGVkdWxlVGFyZ2V0VHlwZRIkCiBTQ0hFRFVMRV9UQVJHRVRfVFlQRV9VTlNQRUNJRklFRBAAEh0KGVNDSEVEVUxFX1RBUkdFVF9UWVBFX1JBQ0sQARIeChpTQ0hFRFVMRV9UQVJHRVRfVFlQRV9NSU5FUhACEh4KGlNDSEVEVUxFX1RBUkdFVF9UWVBFX0dST1VQEAMyjgUKD1NjaGVkdWxlU2VydmljZRJWCg1MaXN0U2NoZWR1bGVzEiEuc2NoZWR1bGUudjEuTGlzdFNjaGVkdWxlc1JlcXVlc3QaIi5zY2hlZHVsZS52MS5MaXN0U2NoZWR1bGVzUmVzcG9uc2USWQoOQ3JlYXRlU2NoZWR1bGUSIi5zY2hlZHVsZS52MS5DcmVhdGVTY2hlZHVsZVJlcXVlc3QaIy5zY2hlZHVsZS52MS5DcmVhdGVTY2hlZHVsZVJlc3BvbnNlElkKDlVwZGF0ZVNjaGVkdWxlEiIuc2NoZWR1bGUudjEuVXBkYXRlU2NoZWR1bGVSZXF1ZXN0GiMuc2NoZWR1bGUudjEuVXBkYXRlU2NoZWR1bGVSZXNwb25zZRJZCg5EZWxldGVTY2hlZHVsZRIiLnNjaGVkdWxlLnYxLkRlbGV0ZVNjaGVkdWxlUmVxdWVzdBojLnNjaGVkdWxlLnYxLkRlbGV0ZVNjaGVkdWxlUmVzcG9uc2USVgoNUGF1c2VTY2hlZHVsZRIhLnNjaGVkdWxlLnYxLlBhdXNlU2NoZWR1bGVSZXF1ZXN0GiIuc2NoZWR1bGUudjEuUGF1c2VTY2hlZHVsZVJlc3BvbnNlElkKDlJlc3VtZVNjaGVkdWxlEiIuc2NoZWR1bGUudjEuUmVzdW1lU2NoZWR1bGVSZXF1ZXN0GiMuc2NoZWR1bGUudjEuUmVzdW1lU2NoZWR1bGVSZXNwb25zZRJfChBSZW9yZGVyU2NoZWR1bGVzEiQuc2NoZWR1bGUudjEuUmVvcmRlclNjaGVkdWxlc1JlcXVlc3QaJS5zY2hlZHVsZS52MS5SZW9yZGVyU2NoZWR1bGVzUmVzcG9uc2VCuAEKD2NvbS5zY2hlZHVsZS52MUINU2NoZWR1bGVQcm90b1ABWklnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9zY2hlZHVsZS92MTtzY2hlZHVsZXYxogIDU1hYqgILU2NoZWR1bGUuVjHKAgtTY2hlZHVsZVxWMeICF1NjaGVkdWxlXFYxXEdQQk1ldGFkYXRh6gIMU2NoZWR1bGU6OlYxYgZwcm90bzM", + [file_google_protobuf_timestamp, file_buf_validate_validate], + ); + +/** + * Configuration for the power target action + * + * @generated from message schedule.v1.PowerTargetConfig + */ +export type PowerTargetConfig = Message<"schedule.v1.PowerTargetConfig"> & { + /** + * @generated from field: schedule.v1.PowerTargetMode mode = 1; + */ + mode: PowerTargetMode; +}; + +/** + * Describes the message schedule.v1.PowerTargetConfig. + * Use `create(PowerTargetConfigSchema)` to create a new message. + */ +export const PowerTargetConfigSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 0); + +/** + * Recurrence configuration for recurring schedules + * + * @generated from message schedule.v1.ScheduleRecurrence + */ +export type ScheduleRecurrence = Message<"schedule.v1.ScheduleRecurrence"> & { + /** + * How often the schedule repeats + * + * @generated from field: schedule.v1.RecurrenceFrequency frequency = 1; + */ + frequency: RecurrenceFrequency; + + /** + * Repeat interval. Must be 1. + * + * @generated from field: int32 interval = 2; + */ + interval: number; + + /** + * Days of the week for weekly recurrence (at least one required when frequency is WEEKLY) + * + * @generated from field: repeated schedule.v1.DayOfWeek days_of_week = 3; + */ + daysOfWeek: DayOfWeek[]; + + /** + * Day of month for monthly recurrence (1-31). Months without this day are + * skipped (e.g., day 31 skips Feb, Apr, Jun, Sep, Nov). + * + * @generated from field: optional int32 day_of_month = 4; + */ + dayOfMonth?: number; +}; + +/** + * Describes the message schedule.v1.ScheduleRecurrence. + * Use `create(ScheduleRecurrenceSchema)` to create a new message. + */ +export const ScheduleRecurrenceSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 1); + +/** + * Target for a schedule (rack, group, or individual miner) + * + * @generated from message schedule.v1.ScheduleTarget + */ +export type ScheduleTarget = Message<"schedule.v1.ScheduleTarget"> & { + /** + * Type of target: rack, group, or miner + * + * @generated from field: schedule.v1.ScheduleTargetType target_type = 1; + */ + targetType: ScheduleTargetType; + + /** + * Identifier for the target (rack ID, group ID, or miner device identifier) + * + * @generated from field: string target_id = 2; + */ + targetId: string; +}; + +/** + * Describes the message schedule.v1.ScheduleTarget. + * Use `create(ScheduleTargetSchema)` to create a new message. + */ +export const ScheduleTargetSchema: GenMessage = /*@__PURE__*/ messageDesc(file_schedule_v1_schedule, 2); + +/** + * Full schedule entity + * + * @generated from message schedule.v1.Schedule + */ +export type Schedule = Message<"schedule.v1.Schedule"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: schedule.v1.ScheduleAction action = 3; + */ + action: ScheduleAction; + + /** + * @generated from field: schedule.v1.PowerTargetConfig action_config = 4; + */ + actionConfig?: PowerTargetConfig; + + /** + * @generated from field: schedule.v1.ScheduleType schedule_type = 5; + */ + scheduleType: ScheduleType; + + /** + * @generated from field: schedule.v1.ScheduleRecurrence recurrence = 6; + */ + recurrence?: ScheduleRecurrence; + + /** + * @generated from field: string start_date = 7; + */ + startDate: string; + + /** + * @generated from field: string start_time = 8; + */ + startTime: string; + + /** + * @generated from field: string end_time = 9; + */ + endTime: string; + + /** + * @generated from field: string end_date = 11; + */ + endDate: string; + + /** + * @generated from field: string timezone = 13; + */ + timezone: string; + + /** + * @generated from field: schedule.v1.ScheduleStatus status = 14; + */ + status: ScheduleStatus; + + /** + * @generated from field: int32 priority = 15; + */ + priority: number; + + /** + * @generated from field: int64 created_by = 17; + */ + createdBy: bigint; + + /** + * @generated from field: google.protobuf.Timestamp created_at = 18; + */ + createdAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp updated_at = 19; + */ + updatedAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp last_run_at = 20; + */ + lastRunAt?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp next_run_at = 21; + */ + nextRunAt?: Timestamp; + + /** + * @generated from field: repeated schedule.v1.ScheduleTarget targets = 23; + */ + targets: ScheduleTarget[]; + + /** + * @generated from field: string created_by_username = 24; + */ + createdByUsername: string; +}; + +/** + * Describes the message schedule.v1.Schedule. + * Use `create(ScheduleSchema)` to create a new message. + */ +export const ScheduleSchema: GenMessage = /*@__PURE__*/ messageDesc(file_schedule_v1_schedule, 3); + +/** + * @generated from message schedule.v1.ListSchedulesRequest + */ +export type ListSchedulesRequest = Message<"schedule.v1.ListSchedulesRequest"> & { + /** + * Filter by status (optional, returns all statuses if unspecified) + * + * @generated from field: schedule.v1.ScheduleStatus status = 1; + */ + status: ScheduleStatus; + + /** + * Filter by action type (optional, returns all actions if unspecified) + * + * @generated from field: schedule.v1.ScheduleAction action = 2; + */ + action: ScheduleAction; +}; + +/** + * Describes the message schedule.v1.ListSchedulesRequest. + * Use `create(ListSchedulesRequestSchema)` to create a new message. + */ +export const ListSchedulesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 4); + +/** + * @generated from message schedule.v1.ListSchedulesResponse + */ +export type ListSchedulesResponse = Message<"schedule.v1.ListSchedulesResponse"> & { + /** + * @generated from field: repeated schedule.v1.Schedule schedules = 1; + */ + schedules: Schedule[]; +}; + +/** + * Describes the message schedule.v1.ListSchedulesResponse. + * Use `create(ListSchedulesResponseSchema)` to create a new message. + */ +export const ListSchedulesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 5); + +/** + * @generated from message schedule.v1.CreateScheduleRequest + */ +export type CreateScheduleRequest = Message<"schedule.v1.CreateScheduleRequest"> & { + /** + * Schedule name (required, 1-100 characters) + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Action to perform (required) + * + * @generated from field: schedule.v1.ScheduleAction action = 2; + */ + action: ScheduleAction; + + /** + * Power target configuration (required when action is SET_POWER_TARGET) + * + * @generated from field: schedule.v1.PowerTargetConfig action_config = 3; + */ + actionConfig?: PowerTargetConfig; + + /** + * Schedule type (required) + * + * @generated from field: schedule.v1.ScheduleType schedule_type = 4; + */ + scheduleType: ScheduleType; + + /** + * Recurrence configuration (required when schedule_type is RECURRING) + * + * @generated from field: schedule.v1.ScheduleRecurrence recurrence = 5; + */ + recurrence?: ScheduleRecurrence; + + /** + * Start date in YYYY-MM-DD format (required) + * + * @generated from field: string start_date = 6; + */ + startDate: string; + + /** + * Start time in HH:MM format (required) + * + * @generated from field: string start_time = 7; + */ + startTime: string; + + /** + * End time in HH:MM format (for power target time windows) + * + * @generated from field: string end_time = 8; + */ + endTime: string; + + /** + * End date in YYYY-MM-DD format (if set, schedule ends on this date; if absent, runs indefinitely) + * + * @generated from field: string end_date = 10; + */ + endDate: string; + + /** + * IANA timezone string (required, e.g. "America/Chicago") + * + * @generated from field: string timezone = 12; + */ + timezone: string; + + /** + * Targets for this schedule (racks and/or miners) + * + * @generated from field: repeated schedule.v1.ScheduleTarget targets = 14; + */ + targets: ScheduleTarget[]; +}; + +/** + * Describes the message schedule.v1.CreateScheduleRequest. + * Use `create(CreateScheduleRequestSchema)` to create a new message. + */ +export const CreateScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 6); + +/** + * @generated from message schedule.v1.CreateScheduleResponse + */ +export type CreateScheduleResponse = Message<"schedule.v1.CreateScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.CreateScheduleResponse. + * Use `create(CreateScheduleResponseSchema)` to create a new message. + */ +export const CreateScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 7); + +/** + * @generated from message schedule.v1.UpdateScheduleRequest + */ +export type UpdateScheduleRequest = Message<"schedule.v1.UpdateScheduleRequest"> & { + /** + * ID of the schedule to update (required) + * + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; + + /** + * Schedule name (required, 1-100 characters) + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Action to perform (required) + * + * @generated from field: schedule.v1.ScheduleAction action = 3; + */ + action: ScheduleAction; + + /** + * Power target configuration (required when action is SET_POWER_TARGET) + * + * @generated from field: schedule.v1.PowerTargetConfig action_config = 4; + */ + actionConfig?: PowerTargetConfig; + + /** + * Schedule type (required) + * + * @generated from field: schedule.v1.ScheduleType schedule_type = 5; + */ + scheduleType: ScheduleType; + + /** + * Recurrence configuration (required when schedule_type is RECURRING) + * + * @generated from field: schedule.v1.ScheduleRecurrence recurrence = 6; + */ + recurrence?: ScheduleRecurrence; + + /** + * Start date in YYYY-MM-DD format (required) + * + * @generated from field: string start_date = 7; + */ + startDate: string; + + /** + * Start time in HH:MM format (required) + * + * @generated from field: string start_time = 8; + */ + startTime: string; + + /** + * End time in HH:MM format (for power target time windows) + * + * @generated from field: string end_time = 9; + */ + endTime: string; + + /** + * End date in YYYY-MM-DD format (if set, schedule ends on this date; if absent, runs indefinitely) + * + * @generated from field: string end_date = 11; + */ + endDate: string; + + /** + * IANA timezone string (required, e.g. "America/Chicago") + * + * @generated from field: string timezone = 13; + */ + timezone: string; + + /** + * Updated targets (replaces all existing targets) + * + * @generated from field: repeated schedule.v1.ScheduleTarget targets = 15; + */ + targets: ScheduleTarget[]; +}; + +/** + * Describes the message schedule.v1.UpdateScheduleRequest. + * Use `create(UpdateScheduleRequestSchema)` to create a new message. + */ +export const UpdateScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 8); + +/** + * @generated from message schedule.v1.UpdateScheduleResponse + */ +export type UpdateScheduleResponse = Message<"schedule.v1.UpdateScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.UpdateScheduleResponse. + * Use `create(UpdateScheduleResponseSchema)` to create a new message. + */ +export const UpdateScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 9); + +/** + * @generated from message schedule.v1.DeleteScheduleRequest + */ +export type DeleteScheduleRequest = Message<"schedule.v1.DeleteScheduleRequest"> & { + /** + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; +}; + +/** + * Describes the message schedule.v1.DeleteScheduleRequest. + * Use `create(DeleteScheduleRequestSchema)` to create a new message. + */ +export const DeleteScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 10); + +/** + * @generated from message schedule.v1.DeleteScheduleResponse + */ +export type DeleteScheduleResponse = Message<"schedule.v1.DeleteScheduleResponse"> & {}; + +/** + * Describes the message schedule.v1.DeleteScheduleResponse. + * Use `create(DeleteScheduleResponseSchema)` to create a new message. + */ +export const DeleteScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 11); + +/** + * @generated from message schedule.v1.PauseScheduleRequest + */ +export type PauseScheduleRequest = Message<"schedule.v1.PauseScheduleRequest"> & { + /** + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; +}; + +/** + * Describes the message schedule.v1.PauseScheduleRequest. + * Use `create(PauseScheduleRequestSchema)` to create a new message. + */ +export const PauseScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 12); + +/** + * @generated from message schedule.v1.PauseScheduleResponse + */ +export type PauseScheduleResponse = Message<"schedule.v1.PauseScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.PauseScheduleResponse. + * Use `create(PauseScheduleResponseSchema)` to create a new message. + */ +export const PauseScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 13); + +/** + * @generated from message schedule.v1.ResumeScheduleRequest + */ +export type ResumeScheduleRequest = Message<"schedule.v1.ResumeScheduleRequest"> & { + /** + * @generated from field: int64 schedule_id = 1; + */ + scheduleId: bigint; +}; + +/** + * Describes the message schedule.v1.ResumeScheduleRequest. + * Use `create(ResumeScheduleRequestSchema)` to create a new message. + */ +export const ResumeScheduleRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 14); + +/** + * @generated from message schedule.v1.ResumeScheduleResponse + */ +export type ResumeScheduleResponse = Message<"schedule.v1.ResumeScheduleResponse"> & { + /** + * @generated from field: schedule.v1.Schedule schedule = 1; + */ + schedule?: Schedule; +}; + +/** + * Describes the message schedule.v1.ResumeScheduleResponse. + * Use `create(ResumeScheduleResponseSchema)` to create a new message. + */ +export const ResumeScheduleResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 15); + +/** + * @generated from message schedule.v1.ReorderSchedulesRequest + */ +export type ReorderSchedulesRequest = Message<"schedule.v1.ReorderSchedulesRequest"> & { + /** + * Ordered list of schedule IDs. Position in list determines new priority (index 0 = highest). + * + * @generated from field: repeated int64 schedule_ids = 1; + */ + scheduleIds: bigint[]; +}; + +/** + * Describes the message schedule.v1.ReorderSchedulesRequest. + * Use `create(ReorderSchedulesRequestSchema)` to create a new message. + */ +export const ReorderSchedulesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 16); + +/** + * @generated from message schedule.v1.ReorderSchedulesResponse + */ +export type ReorderSchedulesResponse = Message<"schedule.v1.ReorderSchedulesResponse"> & {}; + +/** + * Describes the message schedule.v1.ReorderSchedulesResponse. + * Use `create(ReorderSchedulesResponseSchema)` to create a new message. + */ +export const ReorderSchedulesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_schedule_v1_schedule, 17); + +/** + * @generated from enum schedule.v1.ScheduleStatus + */ +export enum ScheduleStatus { + /** + * @generated from enum value: SCHEDULE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_STATUS_ACTIVE = 1; + */ + ACTIVE = 1, + + /** + * @generated from enum value: SCHEDULE_STATUS_PAUSED = 2; + */ + PAUSED = 2, + + /** + * @generated from enum value: SCHEDULE_STATUS_RUNNING = 3; + */ + RUNNING = 3, + + /** + * @generated from enum value: SCHEDULE_STATUS_COMPLETED = 4; + */ + COMPLETED = 4, +} + +/** + * Describes the enum schedule.v1.ScheduleStatus. + */ +export const ScheduleStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 0); + +/** + * @generated from enum schedule.v1.ScheduleAction + */ +export enum ScheduleAction { + /** + * @generated from enum value: SCHEDULE_ACTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_ACTION_SET_POWER_TARGET = 1; + */ + SET_POWER_TARGET = 1, + + /** + * @generated from enum value: SCHEDULE_ACTION_REBOOT = 2; + */ + REBOOT = 2, + + /** + * @generated from enum value: SCHEDULE_ACTION_SLEEP = 3; + */ + SLEEP = 3, +} + +/** + * Describes the enum schedule.v1.ScheduleAction. + */ +export const ScheduleActionSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 1); + +/** + * @generated from enum schedule.v1.ScheduleType + */ +export enum ScheduleType { + /** + * @generated from enum value: SCHEDULE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_TYPE_ONE_TIME = 1; + */ + ONE_TIME = 1, + + /** + * @generated from enum value: SCHEDULE_TYPE_RECURRING = 2; + */ + RECURRING = 2, +} + +/** + * Describes the enum schedule.v1.ScheduleType. + */ +export const ScheduleTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 2); + +/** + * @generated from enum schedule.v1.RecurrenceFrequency + */ +export enum RecurrenceFrequency { + /** + * @generated from enum value: RECURRENCE_FREQUENCY_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: RECURRENCE_FREQUENCY_DAILY = 1; + */ + DAILY = 1, + + /** + * @generated from enum value: RECURRENCE_FREQUENCY_WEEKLY = 2; + */ + WEEKLY = 2, + + /** + * @generated from enum value: RECURRENCE_FREQUENCY_MONTHLY = 3; + */ + MONTHLY = 3, +} + +/** + * Describes the enum schedule.v1.RecurrenceFrequency. + */ +export const RecurrenceFrequencySchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_schedule_v1_schedule, 3); + +/** + * Power target mode for set_power_target action. Custom kW deferred to v1.1. + * + * @generated from enum schedule.v1.PowerTargetMode + */ +export enum PowerTargetMode { + /** + * @generated from enum value: POWER_TARGET_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: POWER_TARGET_MODE_DEFAULT = 1; + */ + DEFAULT = 1, + + /** + * @generated from enum value: POWER_TARGET_MODE_MAX = 2; + */ + MAX = 2, +} + +/** + * Describes the enum schedule.v1.PowerTargetMode. + */ +export const PowerTargetModeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 4); + +/** + * Day of week for weekly recurrence + * + * @generated from enum schedule.v1.DayOfWeek + */ +export enum DayOfWeek { + /** + * @generated from enum value: DAY_OF_WEEK_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: DAY_OF_WEEK_SUNDAY = 1; + */ + SUNDAY = 1, + + /** + * @generated from enum value: DAY_OF_WEEK_MONDAY = 2; + */ + MONDAY = 2, + + /** + * @generated from enum value: DAY_OF_WEEK_TUESDAY = 3; + */ + TUESDAY = 3, + + /** + * @generated from enum value: DAY_OF_WEEK_WEDNESDAY = 4; + */ + WEDNESDAY = 4, + + /** + * @generated from enum value: DAY_OF_WEEK_THURSDAY = 5; + */ + THURSDAY = 5, + + /** + * @generated from enum value: DAY_OF_WEEK_FRIDAY = 6; + */ + FRIDAY = 6, + + /** + * @generated from enum value: DAY_OF_WEEK_SATURDAY = 7; + */ + SATURDAY = 7, +} + +/** + * Describes the enum schedule.v1.DayOfWeek. + */ +export const DayOfWeekSchema: GenEnum = /*@__PURE__*/ enumDesc(file_schedule_v1_schedule, 5); + +/** + * Target type for a schedule + * + * @generated from enum schedule.v1.ScheduleTargetType + */ +export enum ScheduleTargetType { + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_RACK = 1; + */ + RACK = 1, + + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_MINER = 2; + */ + MINER = 2, + + /** + * @generated from enum value: SCHEDULE_TARGET_TYPE_GROUP = 3; + */ + GROUP = 3, +} + +/** + * Describes the enum schedule.v1.ScheduleTargetType. + */ +export const ScheduleTargetTypeSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_schedule_v1_schedule, 6); + +/** + * Service for managing scheduled miner operations (power target changes, reboots, and sleep) + * with priority-based conflict resolution. + * + * @generated from service schedule.v1.ScheduleService + */ +export const ScheduleService: GenService<{ + /** + * Lists all schedules for the organization, ordered by priority + * + * @generated from rpc schedule.v1.ScheduleService.ListSchedules + */ + listSchedules: { + methodKind: "unary"; + input: typeof ListSchedulesRequestSchema; + output: typeof ListSchedulesResponseSchema; + }; + /** + * Creates a new schedule + * + * @generated from rpc schedule.v1.ScheduleService.CreateSchedule + */ + createSchedule: { + methodKind: "unary"; + input: typeof CreateScheduleRequestSchema; + output: typeof CreateScheduleResponseSchema; + }; + /** + * Updates an existing schedule + * + * @generated from rpc schedule.v1.ScheduleService.UpdateSchedule + */ + updateSchedule: { + methodKind: "unary"; + input: typeof UpdateScheduleRequestSchema; + output: typeof UpdateScheduleResponseSchema; + }; + /** + * Soft-deletes a schedule + * + * @generated from rpc schedule.v1.ScheduleService.DeleteSchedule + */ + deleteSchedule: { + methodKind: "unary"; + input: typeof DeleteScheduleRequestSchema; + output: typeof DeleteScheduleResponseSchema; + }; + /** + * Pauses an active schedule + * + * @generated from rpc schedule.v1.ScheduleService.PauseSchedule + */ + pauseSchedule: { + methodKind: "unary"; + input: typeof PauseScheduleRequestSchema; + output: typeof PauseScheduleResponseSchema; + }; + /** + * Resumes a paused schedule + * + * @generated from rpc schedule.v1.ScheduleService.ResumeSchedule + */ + resumeSchedule: { + methodKind: "unary"; + input: typeof ResumeScheduleRequestSchema; + output: typeof ResumeScheduleResponseSchema; + }; + /** + * Batch-updates schedule priorities based on ordered list of IDs + * + * @generated from rpc schedule.v1.ScheduleService.ReorderSchedules + */ + reorderSchedules: { + methodKind: "unary"; + input: typeof ReorderSchedulesRequestSchema; + output: typeof ReorderSchedulesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_schedule_v1_schedule, 0); diff --git a/client/src/protoFleet/api/generated/telemetry/v1/telemetry_pb.ts b/client/src/protoFleet/api/generated/telemetry/v1/telemetry_pb.ts new file mode 100644 index 000000000..68df763b8 --- /dev/null +++ b/client/src/protoFleet/api/generated/telemetry/v1/telemetry_pb.ts @@ -0,0 +1,1066 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts" +// @generated from file telemetry/v1/telemetry.proto (package telemetry.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_duration, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { MeasurementUnit } from "../../common/v1/measurement_pb"; +import { file_common_v1_measurement } from "../../common/v1/measurement_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file telemetry/v1/telemetry.proto. + */ +export const file_telemetry_v1_telemetry: GenFile = + /*@__PURE__*/ + fileDesc( + "Chx0ZWxlbWV0cnkvdjEvdGVsZW1ldHJ5LnByb3RvEgx0ZWxlbWV0cnkudjEiagoORGV2aWNlU2VsZWN0b3ISFQoLYWxsX2RldmljZXMYASABKAhIABIvCgtkZXZpY2VfbGlzdBgCIAEoCzIYLnRlbGVtZXRyeS52MS5EZXZpY2VMaXN0SABCEAoOc2VsZWN0b3JfdmFsdWUiIAoKRGV2aWNlTGlzdBISCgpkZXZpY2VfaWRzGAEgAygJIpgBChZUZW1wZXJhdHVyZVN0YXR1c0NvdW50Ei0KCXRpbWVzdGFtcBgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKY29sZF9jb3VudBgCIAEoBRIQCghva19jb3VudBgDIAEoBRIRCglob3RfY291bnQYBCABKAUSFgoOY3JpdGljYWxfY291bnQYBSABKAUidAoRVXB0aW1lU3RhdHVzQ291bnQSLQoJdGltZXN0YW1wGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1oYXNoaW5nX2NvdW50GAIgASgFEhkKEW5vdF9oYXNoaW5nX2NvdW50GAMgASgFIo8BCglUaW1lUmFuZ2USMwoKc3RhcnRfdGltZRgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAIgBARIxCghlbmRfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBIAYgBAUINCgtfc3RhcnRfdGltZUILCglfZW5kX3RpbWUipQIKDVRlbGVtZXRyeURhdGESEQoJZGV2aWNlX2lkGAEgASgJEjcKEG1lYXN1cmVtZW50X3R5cGUYAiABKA4yHS50ZWxlbWV0cnkudjEuTWVhc3VyZW1lbnRUeXBlEg0KBXZhbHVlGAMgASgBEigKBHVuaXQYBCABKA4yGi5jb21tb24udjEuTWVhc3VyZW1lbnRVbml0Ei0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoEdGFncxgGIAMoCzIlLnRlbGVtZXRyeS52MS5UZWxlbWV0cnlEYXRhLlRhZ3NFbnRyeRorCglUYWdzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASLIAgoORGV2aWNlTWV0YWRhdGESEQoJZGV2aWNlX2lkGAEgASgJEhgKC2RldmljZV90eXBlGAIgASgJSACIAQESLQoJbGFzdF9zZWVuGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBItCgZzdGF0dXMYBCABKA4yHS50ZWxlbWV0cnkudjEuQ29tcG9uZW50U3RhdHVzEhUKCGxvY2F0aW9uGAUgASgJSAGIAQESNAoEdGFncxgGIAMoCzImLnRlbGVtZXRyeS52MS5EZXZpY2VNZXRhZGF0YS5UYWdzRW50cnkSFAoMY2FwYWJpbGl0aWVzGAcgAygJGisKCVRhZ3NFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQg4KDF9kZXZpY2VfdHlwZUILCglfbG9jYXRpb24i1AIKE0FnZ3JlZ2F0ZWRUZWxlbWV0cnkSEQoJZGV2aWNlX2lkGAEgASgJEjcKEG1lYXN1cmVtZW50X3R5cGUYAiABKA4yHS50ZWxlbWV0cnkudjEuTWVhc3VyZW1lbnRUeXBlEg0KBXZhbHVlGAMgASgBEjcKEGFnZ3JlZ2F0aW9uX3R5cGUYBCABKA4yHS50ZWxlbWV0cnkudjEuQWdncmVnYXRpb25UeXBlEhMKC2RhdGFfcG9pbnRzGAUgASgFEiwKC3RpbWVfd2luZG93GAYgASgLMhcudGVsZW1ldHJ5LnYxLlRpbWVSYW5nZRI5CgR0YWdzGAcgAygLMisudGVsZW1ldHJ5LnYxLkFnZ3JlZ2F0ZWRUZWxlbWV0cnkuVGFnc0VudHJ5GisKCVRhZ3NFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIm4KEE1pbmVyU3RhdGVDb3VudHMSFQoNaGFzaGluZ19jb3VudBgBIAEoBRIUCgxicm9rZW5fY291bnQYAiABKAUSFQoNb2ZmbGluZV9jb3VudBgDIAEoBRIWCg5zbGVlcGluZ19jb3VudBgEIAEoBSLWAwoPVGVsZW1ldHJ5VXBkYXRlEiYKBHR5cGUYASABKA4yGC50ZWxlbWV0cnkudjEuVXBkYXRlVHlwZRIWCglkZXZpY2VfaWQYAiABKAlIAIgBARItCgl0aW1lc3RhbXAYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KBGRhdGEYBCABKAsyGy50ZWxlbWV0cnkudjEuVGVsZW1ldHJ5RGF0YUgBiAEBEhoKDWVycm9yX21lc3NhZ2UYBSABKAlIAogBARIyCgZzdGF0dXMYBiABKA4yHS50ZWxlbWV0cnkudjEuQ29tcG9uZW50U3RhdHVzSAOIAQESNgoNZGV2aWNlX3N0YXR1cxgHIAEoDjIaLnRlbGVtZXRyeS52MS5EZXZpY2VTdGF0dXNIBIgBARI/ChJtaW5lcl9zdGF0ZV9jb3VudHMYCCABKAsyHi50ZWxlbWV0cnkudjEuTWluZXJTdGF0ZUNvdW50c0gFiAEBQgwKCl9kZXZpY2VfaWRCBwoFX2RhdGFCEAoOX2Vycm9yX21lc3NhZ2VCCQoHX3N0YXR1c0IQCg5fZGV2aWNlX3N0YXR1c0IVChNfbWluZXJfc3RhdGVfY291bnRzIuwDChlHZXRDb21iaW5lZE1ldHJpY3NSZXF1ZXN0Ej0KD2RldmljZV9zZWxlY3RvchgBIAEoCzIcLnRlbGVtZXRyeS52MS5EZXZpY2VTZWxlY3RvckIGukgDyAEBEjgKEW1lYXN1cmVtZW50X3R5cGVzGAMgAygOMh0udGVsZW1ldHJ5LnYxLk1lYXN1cmVtZW50VHlwZRIzCgxhZ2dyZWdhdGlvbnMYBCADKA4yHS50ZWxlbWV0cnkudjEuQWdncmVnYXRpb25UeXBlEi4KC2dyYW51bGFyaXR5GAUgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEjYKCnN0YXJ0X3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQga6SAPIAQESLAoIZW5kX3RpbWUYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCnBhZ2VfdG9rZW4YCCABKAkSEQoJcGFnZV9zaXplGAkgASgFOmS6SGEaXwoZZW5kX3RpbWVfYWZ0ZXJfc3RhcnRfdGltZRIhZW5kX3RpbWUgbXVzdCBiZSBhZnRlciBzdGFydF90aW1lGh90aGlzLmVuZF90aW1lID4gdGhpcy5zdGFydF90aW1lIlkKD0FnZ3JlZ2F0ZWRWYWx1ZRI3ChBhZ2dyZWdhdGlvbl90eXBlGAEgASgOMh0udGVsZW1ldHJ5LnYxLkFnZ3JlZ2F0aW9uVHlwZRINCgV2YWx1ZRgCIAEoASLAAQoGTWV0cmljEjcKEG1lYXN1cmVtZW50X3R5cGUYASABKA4yHS50ZWxlbWV0cnkudjEuTWVhc3VyZW1lbnRUeXBlEi0KCW9wZW5fdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOAoRYWdncmVnYXRlZF92YWx1ZXMYAyADKAsyHS50ZWxlbWV0cnkudjEuQWdncmVnYXRlZFZhbHVlEhQKDGRldmljZV9jb3VudBgEIAEoBSLkAQoaR2V0Q29tYmluZWRNZXRyaWNzUmVzcG9uc2USJQoHbWV0cmljcxgBIAMoCzIULnRlbGVtZXRyeS52MS5NZXRyaWMSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEkcKGXRlbXBlcmF0dXJlX3N0YXR1c19jb3VudHMYAyADKAsyJC50ZWxlbWV0cnkudjEuVGVtcGVyYXR1cmVTdGF0dXNDb3VudBI9ChR1cHRpbWVfc3RhdHVzX2NvdW50cxgEIAMoCzIfLnRlbGVtZXRyeS52MS5VcHRpbWVTdGF0dXNDb3VudCK+AgoiU3RyZWFtQ29tYmluZWRNZXRyaWNVcGRhdGVzUmVxdWVzdBI9Cg9kZXZpY2Vfc2VsZWN0b3IYASABKAsyHC50ZWxlbWV0cnkudjEuRGV2aWNlU2VsZWN0b3JCBrpIA8gBARIuCgdtZXRyaWNzGAIgAygOMh0udGVsZW1ldHJ5LnYxLk1lYXN1cmVtZW50VHlwZRIzCgxhZ2dyZWdhdGlvbnMYAyADKA4yHS50ZWxlbWV0cnkudjEuQWdncmVnYXRpb25UeXBlEkAKC2dyYW51bGFyaXR5GAQgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uQhC6SA2qAQoiBAiAowUyAggKEjIKD3VwZGF0ZV9pbnRlcnZhbBgFIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbiLGAgojU3RyZWFtQ29tYmluZWRNZXRyaWNVcGRhdGVzUmVzcG9uc2USJQoHbWV0cmljcxgBIAMoCzIULnRlbGVtZXRyeS52MS5NZXRyaWMSNAoQbmV4dF91cGRhdGVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASRwoZdGVtcGVyYXR1cmVfc3RhdHVzX2NvdW50cxgDIAMoCzIkLnRlbGVtZXRyeS52MS5UZW1wZXJhdHVyZVN0YXR1c0NvdW50Ej0KFHVwdGltZV9zdGF0dXNfY291bnRzGAQgAygLMh8udGVsZW1ldHJ5LnYxLlVwdGltZVN0YXR1c0NvdW50EjoKEm1pbmVyX3N0YXRlX2NvdW50cxgFIAEoCzIeLnRlbGVtZXRyeS52MS5NaW5lclN0YXRlQ291bnRzKssCCg9NZWFzdXJlbWVudFR5cGUSIAocTUVBU1VSRU1FTlRfVFlQRV9VTlNQRUNJRklFRBAAEiAKHE1FQVNVUkVNRU5UX1RZUEVfVEVNUEVSQVRVUkUQARIdChlNRUFTVVJFTUVOVF9UWVBFX0hBU0hSQVRFEAISGgoWTUVBU1VSRU1FTlRfVFlQRV9QT1dFUhADEh8KG01FQVNVUkVNRU5UX1RZUEVfRUZGSUNJRU5DWRAEEh4KGk1FQVNVUkVNRU5UX1RZUEVfRkFOX1NQRUVEEAUSHAoYTUVBU1VSRU1FTlRfVFlQRV9WT0xUQUdFEAYSHAoYTUVBU1VSRU1FTlRfVFlQRV9DVVJSRU5UEAcSGwoXTUVBU1VSRU1FTlRfVFlQRV9VUFRJTUUQCBIfChtNRUFTVVJFTUVOVF9UWVBFX0VSUk9SX1JBVEUQCSq9AgoPQWdncmVnYXRpb25UeXBlEiAKHEFHR1JFR0FUSU9OX1RZUEVfVU5TUEVDSUZJRUQQABIcChhBR0dSRUdBVElPTl9UWVBFX0FWRVJBR0UQARIYChRBR0dSRUdBVElPTl9UWVBFX01JThACEhgKFEFHR1JFR0FUSU9OX1RZUEVfTUFYEAMSGAoUQUdHUkVHQVRJT05fVFlQRV9TVU0QBBIjCh9BR0dSRUdBVElPTl9UWVBFX0ZJUlNUX1FVQVJUSUxFEAUSGwoXQUdHUkVHQVRJT05fVFlQRV9NRURJQU4QBhIjCh9BR0dSRUdBVElPTl9UWVBFX1RISVJEX1FVQVJUSUxFEAcSGgoWQUdHUkVHQVRJT05fVFlQRV9GSVJTVBAIEhkKFUFHR1JFR0FUSU9OX1RZUEVfTEFTVBAJKqwBCg9Db21wb25lbnRTdGF0dXMSIAocQ09NUE9ORU5UX1NUQVRVU19VTlNQRUNJRklFRBAAEhwKGENPTVBPTkVOVF9TVEFUVVNfSEVBTFRIWRABEhwKGENPTVBPTkVOVF9TVEFUVVNfV0FSTklORxACEh0KGUNPTVBPTkVOVF9TVEFUVVNfQ1JJVElDQUwQAxIcChhDT01QT05FTlRfU1RBVFVTX09GRkxJTkUQBCqsAQoRVGVtcGVyYXR1cmVTdGF0dXMSIgoeVEVNUEVSQVRVUkVfU1RBVFVTX1VOU1BFQ0lGSUVEEAASGwoXVEVNUEVSQVRVUkVfU1RBVFVTX0NPTEQQARIZChVURU1QRVJBVFVSRV9TVEFUVVNfT0sQAhIaChZURU1QRVJBVFVSRV9TVEFUVVNfSE9UEAMSHwobVEVNUEVSQVRVUkVfU1RBVFVTX0NSSVRJQ0FMEAQqmgIKDERldmljZVN0YXR1cxIdChlERVZJQ0VfU1RBVFVTX1VOU1BFQ0lGSUVEEAASGAoUREVWSUNFX1NUQVRVU19PTkxJTkUQARIZChVERVZJQ0VfU1RBVFVTX09GRkxJTkUQAhIdChlERVZJQ0VfU1RBVFVTX01BSU5URU5BTkNFEAMSFwoTREVWSUNFX1NUQVRVU19FUlJPUhAEEhoKFkRFVklDRV9TVEFUVVNfSU5BQ1RJVkUQBRIjCh9ERVZJQ0VfU1RBVFVTX05FRURTX01JTklOR19QT09MEAYSGgoWREVWSUNFX1NUQVRVU19VUERBVElORxAHEiEKHURFVklDRV9TVEFUVVNfUkVCT09UX1JFUVVJUkVEEAgquQEKClVwZGF0ZVR5cGUSGwoXVVBEQVRFX1RZUEVfVU5TUEVDSUZJRUQQABIZChVVUERBVEVfVFlQRV9URUxFTUVUUlkQARIZChVVUERBVEVfVFlQRV9IRUFSVEJFQVQQAhIVChFVUERBVEVfVFlQRV9FUlJPUhADEh0KGVVQREFURV9UWVBFX0RFVklDRV9TVEFUVVMQBBIiCh5VUERBVEVfVFlQRV9NSU5FUl9TVEFURV9DT1VOVFMQBTKGAgoQVGVsZW1ldHJ5U2VydmljZRJpChJHZXRDb21iaW5lZE1ldHJpY3MSJy50ZWxlbWV0cnkudjEuR2V0Q29tYmluZWRNZXRyaWNzUmVxdWVzdBooLnRlbGVtZXRyeS52MS5HZXRDb21iaW5lZE1ldHJpY3NSZXNwb25zZSIAEoYBChtTdHJlYW1Db21iaW5lZE1ldHJpY1VwZGF0ZXMSMC50ZWxlbWV0cnkudjEuU3RyZWFtQ29tYmluZWRNZXRyaWNVcGRhdGVzUmVxdWVzdBoxLnRlbGVtZXRyeS52MS5TdHJlYW1Db21iaW5lZE1ldHJpY1VwZGF0ZXNSZXNwb25zZSIAMAFCwAEKEGNvbS50ZWxlbWV0cnkudjFCDlRlbGVtZXRyeVByb3RvUAFaS2dpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL3RlbGVtZXRyeS92MTt0ZWxlbWV0cnl2MaICA1RYWKoCDFRlbGVtZXRyeS5WMcoCDFRlbGVtZXRyeVxWMeICGFRlbGVtZXRyeVxWMVxHUEJNZXRhZGF0YeoCDVRlbGVtZXRyeTo6VjFiBnByb3RvMw", + [ + file_google_protobuf_timestamp, + file_google_protobuf_duration, + file_buf_validate_validate, + file_common_v1_measurement, + ], + ); + +/** + * @generated from message telemetry.v1.DeviceSelector + */ +export type DeviceSelector = Message<"telemetry.v1.DeviceSelector"> & { + /** + * @generated from oneof telemetry.v1.DeviceSelector.selector_value + */ + selectorValue: + | { + /** + * Select all devices in the org + * + * @generated from field: bool all_devices = 1; + */ + value: boolean; + case: "allDevices"; + } + | { + /** + * Select specific devices by ID + * + * @generated from field: telemetry.v1.DeviceList device_list = 2; + */ + value: DeviceList; + case: "deviceList"; + } + | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message telemetry.v1.DeviceSelector. + * Use `create(DeviceSelectorSchema)` to create a new message. + */ +export const DeviceSelectorSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 0); + +/** + * @generated from message telemetry.v1.DeviceList + */ +export type DeviceList = Message<"telemetry.v1.DeviceList"> & { + /** + * List of device identifiers (e.g., "proto-miner-001"). + * Note: Despite the field name, these are unique device identifier strings, + * not database primary key IDs. + * + * @generated from field: repeated string device_ids = 1; + */ + deviceIds: string[]; +}; + +/** + * Describes the message telemetry.v1.DeviceList. + * Use `create(DeviceListSchema)` to create a new message. + */ +export const DeviceListSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 1); + +/** + * Temperature status distribution at a point in time + * + * @generated from message telemetry.v1.TemperatureStatusCount + */ +export type TemperatureStatusCount = Message<"telemetry.v1.TemperatureStatusCount"> & { + /** + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Count of miners < 0°C + * + * @generated from field: int32 cold_count = 2; + */ + coldCount: number; + + /** + * Count of miners 0-70°C + * + * @generated from field: int32 ok_count = 3; + */ + okCount: number; + + /** + * Count of miners 70-90°C + * + * @generated from field: int32 hot_count = 4; + */ + hotCount: number; + + /** + * Count of miners > 90°C + * + * @generated from field: int32 critical_count = 5; + */ + criticalCount: number; +}; + +/** + * Describes the message telemetry.v1.TemperatureStatusCount. + * Use `create(TemperatureStatusCountSchema)` to create a new message. + */ +export const TemperatureStatusCountSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 2); + +/** + * Uptime status distribution at a point in time + * + * @generated from message telemetry.v1.UptimeStatusCount + */ +export type UptimeStatusCount = Message<"telemetry.v1.UptimeStatusCount"> & { + /** + * @generated from field: google.protobuf.Timestamp timestamp = 1; + */ + timestamp?: Timestamp; + + /** + * Count of miners actively hashing + * + * @generated from field: int32 hashing_count = 2; + */ + hashingCount: number; + + /** + * Count of miners not hashing + * + * @generated from field: int32 not_hashing_count = 3; + */ + notHashingCount: number; +}; + +/** + * Describes the message telemetry.v1.UptimeStatusCount. + * Use `create(UptimeStatusCountSchema)` to create a new message. + */ +export const UptimeStatusCountSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 3); + +/** + * Time range using optional fields (best practice) + * + * @generated from message telemetry.v1.TimeRange + */ +export type TimeRange = Message<"telemetry.v1.TimeRange"> & { + /** + * @generated from field: optional google.protobuf.Timestamp start_time = 1; + */ + startTime?: Timestamp; + + /** + * @generated from field: optional google.protobuf.Timestamp end_time = 2; + */ + endTime?: Timestamp; +}; + +/** + * Describes the message telemetry.v1.TimeRange. + * Use `create(TimeRangeSchema)` to create a new message. + */ +export const TimeRangeSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 4); + +/** + * Telemetry data structure + * + * @generated from message telemetry.v1.TelemetryData + */ +export type TelemetryData = Message<"telemetry.v1.TelemetryData"> & { + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: string device_id = 1; + */ + deviceId: string; + + /** + * @generated from field: telemetry.v1.MeasurementType measurement_type = 2; + */ + measurementType: MeasurementType; + + /** + * @generated from field: double value = 3; + */ + value: number; + + /** + * @generated from field: common.v1.MeasurementUnit unit = 4; + */ + unit: MeasurementUnit; + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 5; + */ + timestamp?: Timestamp; + + /** + * @generated from field: map tags = 6; + */ + tags: { [key: string]: string }; +}; + +/** + * Describes the message telemetry.v1.TelemetryData. + * Use `create(TelemetryDataSchema)` to create a new message. + */ +export const TelemetryDataSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 5); + +/** + * Device metadata + * + * @generated from message telemetry.v1.DeviceMetadata + */ +export type DeviceMetadata = Message<"telemetry.v1.DeviceMetadata"> & { + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: string device_id = 1; + */ + deviceId: string; + + /** + * @generated from field: optional string device_type = 2; + */ + deviceType?: string; + + /** + * @generated from field: google.protobuf.Timestamp last_seen = 3; + */ + lastSeen?: Timestamp; + + /** + * @generated from field: telemetry.v1.ComponentStatus status = 4; + */ + status: ComponentStatus; + + /** + * @generated from field: optional string location = 5; + */ + location?: string; + + /** + * @generated from field: map tags = 6; + */ + tags: { [key: string]: string }; + + /** + * @generated from field: repeated string capabilities = 7; + */ + capabilities: string[]; +}; + +/** + * Describes the message telemetry.v1.DeviceMetadata. + * Use `create(DeviceMetadataSchema)` to create a new message. + */ +export const DeviceMetadataSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 6); + +/** + * Aggregated telemetry result + * + * @generated from message telemetry.v1.AggregatedTelemetry + */ +export type AggregatedTelemetry = Message<"telemetry.v1.AggregatedTelemetry"> & { + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: string device_id = 1; + */ + deviceId: string; + + /** + * @generated from field: telemetry.v1.MeasurementType measurement_type = 2; + */ + measurementType: MeasurementType; + + /** + * @generated from field: double value = 3; + */ + value: number; + + /** + * @generated from field: telemetry.v1.AggregationType aggregation_type = 4; + */ + aggregationType: AggregationType; + + /** + * @generated from field: int32 data_points = 5; + */ + dataPoints: number; + + /** + * @generated from field: telemetry.v1.TimeRange time_window = 6; + */ + timeWindow?: TimeRange; + + /** + * @generated from field: map tags = 7; + */ + tags: { [key: string]: string }; +}; + +/** + * Describes the message telemetry.v1.AggregatedTelemetry. + * Use `create(AggregatedTelemetrySchema)` to create a new message. + */ +export const AggregatedTelemetrySchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 7); + +/** + * Represents counts of miners in different states with status-first priority: + * 1. Offline (OFFLINE/NULL status) - highest priority + * 2. Sleeping (MAINTENANCE/INACTIVE status) - second priority + * 3. Needs Attention (NEEDS_MINING_POOL/ERROR/AUTHENTICATION_NEEDED/errors) - only if not offline or sleeping + * 4. Hashing (ACTIVE, no auth needed, no errors) - only if none of the above + * TODO: align the name of the messages, to match the filters MinerStatus -> DeviceStatus + * + * @generated from message telemetry.v1.MinerStateCounts + */ +export type MinerStateCounts = Message<"telemetry.v1.MinerStateCounts"> & { + /** + * Number of miners that are hashing (ACTIVE status, no AUTHENTICATION_NEEDED, no open errors) + * Only counted if not offline, sleeping, or needs attention + * + * @generated from field: int32 hashing_count = 1; + */ + hashingCount: number; + + /** + * Number of miners that need attention (NEEDS_MINING_POOL OR ERROR status OR AUTHENTICATION_NEEDED OR open CRITICAL/MAJOR/MINOR errors) + * Only counted if not offline or sleeping - status takes priority over errors + * + * @generated from field: int32 broken_count = 2; + */ + brokenCount: number; + + /** + * Number of miners that are offline (OFFLINE or NULL status) + * Highest priority - includes devices with AUTHENTICATION_NEEDED and/or open errors + * + * @generated from field: int32 offline_count = 3; + */ + offlineCount: number; + + /** + * Number of miners that are sleeping (MAINTENANCE or INACTIVE status) + * Second priority - includes devices with AUTHENTICATION_NEEDED and/or open errors + * + * @generated from field: int32 sleeping_count = 4; + */ + sleepingCount: number; +}; + +/** + * Describes the message telemetry.v1.MinerStateCounts. + * Use `create(MinerStateCountsSchema)` to create a new message. + */ +export const MinerStateCountsSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 8); + +/** + * Streaming update + * + * @generated from message telemetry.v1.TelemetryUpdate + */ +export type TelemetryUpdate = Message<"telemetry.v1.TelemetryUpdate"> & { + /** + * @generated from field: telemetry.v1.UpdateType type = 1; + */ + type: UpdateType; + + /** + * The device identifier string (e.g., "proto-miner-001"). + * Note: Despite the field name, this is the unique device identifier, + * not the database primary key. + * + * @generated from field: optional string device_id = 2; + */ + deviceId?: string; + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 3; + */ + timestamp?: Timestamp; + + /** + * @generated from field: optional telemetry.v1.TelemetryData data = 4; + */ + data?: TelemetryData; + + /** + * @generated from field: optional string error_message = 5; + */ + errorMessage?: string; + + /** + * @generated from field: optional telemetry.v1.ComponentStatus status = 6; + */ + status?: ComponentStatus; + + /** + * e.g., ACTIVE, INACTIVE, ERROR + * + * @generated from field: optional telemetry.v1.DeviceStatus device_status = 7; + */ + deviceStatus?: DeviceStatus; + + /** + * @generated from field: optional telemetry.v1.MinerStateCounts miner_state_counts = 8; + */ + minerStateCounts?: MinerStateCounts; +}; + +/** + * Describes the message telemetry.v1.TelemetryUpdate. + * Use `create(TelemetryUpdateSchema)` to create a new message. + */ +export const TelemetryUpdateSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 9); + +/** + * @generated from message telemetry.v1.GetCombinedMetricsRequest + */ +export type GetCombinedMetricsRequest = Message<"telemetry.v1.GetCombinedMetricsRequest"> & { + /** + * Select devices by ID or all devices in org + * + * @generated from field: telemetry.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * e.g., TEMPERATURE, HASHRATE Defaults to all types + * + * @generated from field: repeated telemetry.v1.MeasurementType measurement_types = 3; + */ + measurementTypes: MeasurementType[]; + + /** + * e.g., AVERAGE, MIN, MAX Defaults to all aggregations + * + * @generated from field: repeated telemetry.v1.AggregationType aggregations = 4; + */ + aggregations: AggregationType[]; + + /** + * e.g., 1m, 5m, 1h defaults to 10s + * + * @generated from field: google.protobuf.Duration granularity = 5; + */ + granularity?: Duration; + + /** + * inclusive + * + * @generated from field: google.protobuf.Timestamp start_time = 6; + */ + startTime?: Timestamp; + + /** + * @generated from field: google.protobuf.Timestamp end_time = 7; + */ + endTime?: Timestamp; + + /** + * for pagination + * + * @generated from field: string page_token = 8; + */ + pageToken: string; + + /** + * max 1000, default 100 + * + * @generated from field: int32 page_size = 9; + */ + pageSize: number; +}; + +/** + * Describes the message telemetry.v1.GetCombinedMetricsRequest. + * Use `create(GetCombinedMetricsRequestSchema)` to create a new message. + */ +export const GetCombinedMetricsRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 10); + +/** + * @generated from message telemetry.v1.AggregatedValue + */ +export type AggregatedValue = Message<"telemetry.v1.AggregatedValue"> & { + /** + * @generated from field: telemetry.v1.AggregationType aggregation_type = 1; + */ + aggregationType: AggregationType; + + /** + * e.g., 75.5 for AVERAGE, 1000 for MAX + * + * @generated from field: double value = 2; + */ + value: number; +}; + +/** + * Describes the message telemetry.v1.AggregatedValue. + * Use `create(AggregatedValueSchema)` to create a new message. + */ +export const AggregatedValueSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 11); + +/** + * @generated from message telemetry.v1.Metric + */ +export type Metric = Message<"telemetry.v1.Metric"> & { + /** + * @generated from field: telemetry.v1.MeasurementType measurement_type = 1; + */ + measurementType: MeasurementType; + + /** + * @generated from field: google.protobuf.Timestamp open_time = 2; + */ + openTime?: Timestamp; + + /** + * e.g., AVERAGE, MIN, MAX + * + * @generated from field: repeated telemetry.v1.AggregatedValue aggregated_values = 3; + */ + aggregatedValues: AggregatedValue[]; + + /** + * Number of devices reporting this metric + * + * @generated from field: int32 device_count = 4; + */ + deviceCount: number; +}; + +/** + * Describes the message telemetry.v1.Metric. + * Use `create(MetricSchema)` to create a new message. + */ +export const MetricSchema: GenMessage = /*@__PURE__*/ messageDesc(file_telemetry_v1_telemetry, 12); + +/** + * @generated from message telemetry.v1.GetCombinedMetricsResponse + */ +export type GetCombinedMetricsResponse = Message<"telemetry.v1.GetCombinedMetricsResponse"> & { + /** + * @generated from field: repeated telemetry.v1.Metric metrics = 1; + */ + metrics: Metric[]; + + /** + * for pagination + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; + + /** + * Temperature status distribution across the fleet + * + * @generated from field: repeated telemetry.v1.TemperatureStatusCount temperature_status_counts = 3; + */ + temperatureStatusCounts: TemperatureStatusCount[]; + + /** + * Uptime status distribution across the fleet + * + * @generated from field: repeated telemetry.v1.UptimeStatusCount uptime_status_counts = 4; + */ + uptimeStatusCounts: UptimeStatusCount[]; +}; + +/** + * Describes the message telemetry.v1.GetCombinedMetricsResponse. + * Use `create(GetCombinedMetricsResponseSchema)` to create a new message. + */ +export const GetCombinedMetricsResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 13); + +/** + * @generated from message telemetry.v1.StreamCombinedMetricUpdatesRequest + */ +export type StreamCombinedMetricUpdatesRequest = Message<"telemetry.v1.StreamCombinedMetricUpdatesRequest"> & { + /** + * Select devices by ID or all devices in org + * + * @generated from field: telemetry.v1.DeviceSelector device_selector = 1; + */ + deviceSelector?: DeviceSelector; + + /** + * @generated from field: repeated telemetry.v1.MeasurementType metrics = 2; + */ + metrics: MeasurementType[]; + + /** + * e.g., AVERAGE, MIN, MAX + * + * @generated from field: repeated telemetry.v1.AggregationType aggregations = 3; + */ + aggregations: AggregationType[]; + + /** + * default granularity is 1 minute + * + * @generated from field: google.protobuf.Duration granularity = 4; + */ + granularity?: Duration; + + /** + * default update interval is granularity + * + * @generated from field: google.protobuf.Duration update_interval = 5; + */ + updateInterval?: Duration; +}; + +/** + * Describes the message telemetry.v1.StreamCombinedMetricUpdatesRequest. + * Use `create(StreamCombinedMetricUpdatesRequestSchema)` to create a new message. + */ +export const StreamCombinedMetricUpdatesRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 14); + +/** + * @generated from message telemetry.v1.StreamCombinedMetricUpdatesResponse + */ +export type StreamCombinedMetricUpdatesResponse = Message<"telemetry.v1.StreamCombinedMetricUpdatesResponse"> & { + /** + * e.g., TEMPERATURE, HASHRATE + * + * @generated from field: repeated telemetry.v1.Metric metrics = 1; + */ + metrics: Metric[]; + + /** + * when the next update will be sent + * + * @generated from field: google.protobuf.Timestamp next_update_time = 2; + */ + nextUpdateTime?: Timestamp; + + /** + * Real-time temperature status distribution + * + * @generated from field: repeated telemetry.v1.TemperatureStatusCount temperature_status_counts = 3; + */ + temperatureStatusCounts: TemperatureStatusCount[]; + + /** + * Real-time uptime status distribution + * + * @generated from field: repeated telemetry.v1.UptimeStatusCount uptime_status_counts = 4; + */ + uptimeStatusCounts: UptimeStatusCount[]; + + /** + * Real-time miner state counts (hashing, broken, offline, sleeping) + * + * @generated from field: telemetry.v1.MinerStateCounts miner_state_counts = 5; + */ + minerStateCounts?: MinerStateCounts; +}; + +/** + * Describes the message telemetry.v1.StreamCombinedMetricUpdatesResponse. + * Use `create(StreamCombinedMetricUpdatesResponseSchema)` to create a new message. + */ +export const StreamCombinedMetricUpdatesResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_telemetry_v1_telemetry, 15); + +/** + * Enums matching domain models + * + * @generated from enum telemetry.v1.MeasurementType + */ +export enum MeasurementType { + /** + * @generated from enum value: MEASUREMENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: MEASUREMENT_TYPE_TEMPERATURE = 1; + */ + TEMPERATURE = 1, + + /** + * @generated from enum value: MEASUREMENT_TYPE_HASHRATE = 2; + */ + HASHRATE = 2, + + /** + * @generated from enum value: MEASUREMENT_TYPE_POWER = 3; + */ + POWER = 3, + + /** + * @generated from enum value: MEASUREMENT_TYPE_EFFICIENCY = 4; + */ + EFFICIENCY = 4, + + /** + * @generated from enum value: MEASUREMENT_TYPE_FAN_SPEED = 5; + */ + FAN_SPEED = 5, + + /** + * @generated from enum value: MEASUREMENT_TYPE_VOLTAGE = 6; + */ + VOLTAGE = 6, + + /** + * @generated from enum value: MEASUREMENT_TYPE_CURRENT = 7; + */ + CURRENT = 7, + + /** + * @generated from enum value: MEASUREMENT_TYPE_UPTIME = 8; + */ + UPTIME = 8, + + /** + * @generated from enum value: MEASUREMENT_TYPE_ERROR_RATE = 9; + */ + ERROR_RATE = 9, +} + +/** + * Describes the enum telemetry.v1.MeasurementType. + */ +export const MeasurementTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 0); + +/** + * @generated from enum telemetry.v1.AggregationType + */ +export enum AggregationType { + /** + * @generated from enum value: AGGREGATION_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AGGREGATION_TYPE_AVERAGE = 1; + */ + AVERAGE = 1, + + /** + * @generated from enum value: AGGREGATION_TYPE_MIN = 2; + */ + MIN = 2, + + /** + * @generated from enum value: AGGREGATION_TYPE_MAX = 3; + */ + MAX = 3, + + /** + * @generated from enum value: AGGREGATION_TYPE_SUM = 4; + */ + SUM = 4, + + /** + * @generated from enum value: AGGREGATION_TYPE_FIRST_QUARTILE = 5; + */ + FIRST_QUARTILE = 5, + + /** + * @generated from enum value: AGGREGATION_TYPE_MEDIAN = 6; + */ + MEDIAN = 6, + + /** + * @generated from enum value: AGGREGATION_TYPE_THIRD_QUARTILE = 7; + */ + THIRD_QUARTILE = 7, + + /** + * @generated from enum value: AGGREGATION_TYPE_FIRST = 8; + */ + FIRST = 8, + + /** + * @generated from enum value: AGGREGATION_TYPE_LAST = 9; + */ + LAST = 9, +} + +/** + * Describes the enum telemetry.v1.AggregationType. + */ +export const AggregationTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 1); + +/** + * @generated from enum telemetry.v1.ComponentStatus + */ +export enum ComponentStatus { + /** + * @generated from enum value: COMPONENT_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: COMPONENT_STATUS_HEALTHY = 1; + */ + HEALTHY = 1, + + /** + * @generated from enum value: COMPONENT_STATUS_WARNING = 2; + */ + WARNING = 2, + + /** + * @generated from enum value: COMPONENT_STATUS_CRITICAL = 3; + */ + CRITICAL = 3, + + /** + * @generated from enum value: COMPONENT_STATUS_OFFLINE = 4; + */ + OFFLINE = 4, +} + +/** + * Describes the enum telemetry.v1.ComponentStatus. + */ +export const ComponentStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 2); + +/** + * Temperature status based on threshold ranges + * + * @generated from enum telemetry.v1.TemperatureStatus + */ +export enum TemperatureStatus { + /** + * @generated from enum value: TEMPERATURE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Below 0°C + * + * @generated from enum value: TEMPERATURE_STATUS_COLD = 1; + */ + COLD = 1, + + /** + * 0°C to 70°C + * + * @generated from enum value: TEMPERATURE_STATUS_OK = 2; + */ + OK = 2, + + /** + * 70°C to 90°C + * + * @generated from enum value: TEMPERATURE_STATUS_HOT = 3; + */ + HOT = 3, + + /** + * Above 90°C + * + * @generated from enum value: TEMPERATURE_STATUS_CRITICAL = 4; + */ + CRITICAL = 4, +} + +/** + * Describes the enum telemetry.v1.TemperatureStatus. + */ +export const TemperatureStatusSchema: GenEnum = + /*@__PURE__*/ + enumDesc(file_telemetry_v1_telemetry, 3); + +/** + * Status of a miner + * + * @generated from enum telemetry.v1.DeviceStatus + */ +export enum DeviceStatus { + /** + * Status is unknown or not specified + * + * @generated from enum value: DEVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Miner is online and functioning normally + * + * @generated from enum value: DEVICE_STATUS_ONLINE = 1; + */ + ONLINE = 1, + + /** + * Miner is offline and not responding + * + * @generated from enum value: DEVICE_STATUS_OFFLINE = 2; + */ + OFFLINE = 2, + + /** + * Miner is in maintenance mode + * + * @generated from enum value: DEVICE_STATUS_MAINTENANCE = 3; + */ + MAINTENANCE = 3, + + /** + * Miner is in error state + * + * @generated from enum value: DEVICE_STATUS_ERROR = 4; + */ + ERROR = 4, + + /** + * Miner is inactive, not mining but still connected + * + * @generated from enum value: DEVICE_STATUS_INACTIVE = 5; + */ + INACTIVE = 5, + + /** + * Miner is online but needs a mining pool configured to start mining + * + * @generated from enum value: DEVICE_STATUS_NEEDS_MINING_POOL = 6; + */ + NEEDS_MINING_POOL = 6, + + /** + * Miner is receiving a firmware update (install in progress on device) + * + * @generated from enum value: DEVICE_STATUS_UPDATING = 7; + */ + UPDATING = 7, + + /** + * Miner firmware has been installed but requires a reboot to activate + * + * @generated from enum value: DEVICE_STATUS_REBOOT_REQUIRED = 8; + */ + REBOOT_REQUIRED = 8, +} + +/** + * Describes the enum telemetry.v1.DeviceStatus. + */ +export const DeviceStatusSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 4); + +/** + * @generated from enum telemetry.v1.UpdateType + */ +export enum UpdateType { + /** + * @generated from enum value: UPDATE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: UPDATE_TYPE_TELEMETRY = 1; + */ + TELEMETRY = 1, + + /** + * @generated from enum value: UPDATE_TYPE_HEARTBEAT = 2; + */ + HEARTBEAT = 2, + + /** + * @generated from enum value: UPDATE_TYPE_ERROR = 3; + */ + ERROR = 3, + + /** + * @generated from enum value: UPDATE_TYPE_DEVICE_STATUS = 4; + */ + DEVICE_STATUS = 4, + + /** + * Represents counts of miners in different states + * + * @generated from enum value: UPDATE_TYPE_MINER_STATE_COUNTS = 5; + */ + MINER_STATE_COUNTS = 5, +} + +/** + * Describes the enum telemetry.v1.UpdateType. + */ +export const UpdateTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_telemetry_v1_telemetry, 5); + +/** + * Service for retrieving telemetry data from mining devices + * + * @generated from service telemetry.v1.TelemetryService + */ +export const TelemetryService: GenService<{ + /** + * Historical, aggregated candles (pull). + * + * @generated from rpc telemetry.v1.TelemetryService.GetCombinedMetrics + */ + getCombinedMetrics: { + methodKind: "unary"; + input: typeof GetCombinedMetricsRequestSchema; + output: typeof GetCombinedMetricsResponseSchema; + }; + /** + * Live updates pushed by the server (used by dashboard). + * + * @generated from rpc telemetry.v1.TelemetryService.StreamCombinedMetricUpdates + */ + streamCombinedMetricUpdates: { + methodKind: "server_streaming"; + input: typeof StreamCombinedMetricUpdatesRequestSchema; + output: typeof StreamCombinedMetricUpdatesResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_telemetry_v1_telemetry, 0); diff --git a/client/src/protoFleet/api/getErrorMessage.test.ts b/client/src/protoFleet/api/getErrorMessage.test.ts new file mode 100644 index 000000000..d7a9fe014 --- /dev/null +++ b/client/src/protoFleet/api/getErrorMessage.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { getErrorMessage } from "./getErrorMessage"; + +describe("getErrorMessage", () => { + describe("ConnectError prefix stripping", () => { + it("strips [internal] prefix", () => { + const err = new ConnectError("Log bundle too large to download!", Code.Internal); + expect(err.message).toContain("[internal]"); + expect(getErrorMessage(err)).toBe("Log bundle too large to download!"); + }); + + it("strips [not_found] prefix", () => { + const err = new ConnectError("device not found", Code.NotFound); + expect(err.message).toContain("[not_found]"); + expect(getErrorMessage(err)).toBe("device not found"); + }); + + it("strips [already_exists] prefix", () => { + const err = new ConnectError("a collection with this name already exists", Code.AlreadyExists); + expect(err.message).toContain("[already_exists]"); + expect(getErrorMessage(err)).toBe("a collection with this name already exists"); + }); + + it("strips [invalid_argument] prefix", () => { + const err = new ConnectError("username is required", Code.InvalidArgument); + expect(err.message).toContain("[invalid_argument]"); + expect(getErrorMessage(err)).toBe("username is required"); + }); + + it("strips [permission_denied] prefix", () => { + const err = new ConnectError("access denied", Code.PermissionDenied); + expect(err.message).toContain("[permission_denied]"); + expect(getErrorMessage(err)).toBe("access denied"); + }); + }); + + describe("fallback behavior", () => { + it("returns fallback when ConnectError has an empty message", () => { + const err = new ConnectError("", Code.Internal); + expect(getErrorMessage(err, "Something went wrong")).toBe("Something went wrong"); + }); + + it("returns empty string when ConnectError has an empty message and no fallback", () => { + const err = new ConnectError("", Code.Internal); + expect(getErrorMessage(err)).toBe(""); + }); + + it("prefers rawMessage over fallback when both are present", () => { + const err = new ConnectError("specific error", Code.Internal); + expect(getErrorMessage(err, "generic fallback")).toBe("specific error"); + }); + }); + + describe("non-ConnectError inputs", () => { + it("extracts message from a plain Error", () => { + const err = new Error("something broke"); + expect(getErrorMessage(err)).toBe("something broke"); + }); + + it("extracts message from a TypeError", () => { + const err = new TypeError("cannot read property of null"); + expect(getErrorMessage(err)).toBe("cannot read property of null"); + }); + + it("converts a string input to message", () => { + expect(getErrorMessage("raw string error")).toBe("raw string error"); + }); + + it("handles null without crashing", () => { + expect(getErrorMessage(null)).toBe("null"); + }); + + it("handles undefined without crashing", () => { + expect(getErrorMessage(undefined)).toBe("undefined"); + }); + + it("handles a number without crashing", () => { + expect(getErrorMessage(42)).toBe("42"); + }); + + it("uses fallback for non-Error inputs with empty string conversion", () => { + expect(getErrorMessage("", "default message")).toBe("default message"); + }); + }); +}); diff --git a/client/src/protoFleet/api/getErrorMessage.ts b/client/src/protoFleet/api/getErrorMessage.ts new file mode 100644 index 000000000..91ebed478 --- /dev/null +++ b/client/src/protoFleet/api/getErrorMessage.ts @@ -0,0 +1,10 @@ +import { ConnectError } from "@connectrpc/connect"; + +/** + * Extracts user-facing error message from a Connect RPC error. + * Strips protocol-level prefixes like "[internal]" that ConnectError.message includes. + * If a fallback is provided, it is returned when the raw message is empty. + */ +export function getErrorMessage(err: unknown, fallback?: string): string { + return ConnectError.from(err).rawMessage || fallback || ""; +} diff --git a/client/src/protoFleet/api/scheduleEvents.ts b/client/src/protoFleet/api/scheduleEvents.ts new file mode 100644 index 000000000..a6ef55c32 --- /dev/null +++ b/client/src/protoFleet/api/scheduleEvents.ts @@ -0,0 +1,9 @@ +export const SCHEDULES_CHANGED_EVENT = "protoFleet:schedules-changed"; + +export const emitSchedulesChanged = () => { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent(new CustomEvent(SCHEDULES_CHANGED_EVENT)); +}; diff --git a/client/src/protoFleet/api/transport.ts b/client/src/protoFleet/api/transport.ts new file mode 100644 index 000000000..c23c7c82f --- /dev/null +++ b/client/src/protoFleet/api/transport.ts @@ -0,0 +1,10 @@ +import { createConnectTransport } from "@connectrpc/connect-web"; +import { API_PROXY_BASE } from "@/protoFleet/api/constants"; + +const transport = createConnectTransport({ + baseUrl: `${API_PROXY_BASE}/`, + // Include cookies with all requests for session-based authentication + fetch: (input, init) => fetch(input, { ...init, credentials: "include" }), +}); + +export { transport }; diff --git a/client/src/protoFleet/api/useActivity.test.ts b/client/src/protoFleet/api/useActivity.test.ts new file mode 100644 index 000000000..be64ac42c --- /dev/null +++ b/client/src/protoFleet/api/useActivity.test.ts @@ -0,0 +1,223 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { activityClient } from "./clients"; +import { useActivity } from "./useActivity"; +import { + ActivityEntrySchema, + type ActivityFilter, + ActivityFilterSchema, + ListActivitiesResponseSchema, +} from "@/protoFleet/api/generated/activity/v1/activity_pb"; + +vi.mock("./clients", () => ({ + activityClient: { + listActivities: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(({ onError }) => onError?.(new Error("auth error"))); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +function makeEntry(id: string) { + return create(ActivityEntrySchema, { + eventId: id, + eventCategory: "auth", + eventType: "login", + description: `Entry ${id}`, + result: "success", + actorType: "user", + }); +} + +function mockListResponse(entries: ReturnType[], nextPageToken = "", totalCount = 0) { + return create(ListActivitiesResponseSchema, { activities: entries, nextPageToken, totalCount }); +} + +describe("useActivity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetches activities on mount with correct params", async () => { + const entries = [makeEntry("1"), makeEntry("2")]; + vi.mocked(activityClient.listActivities).mockResolvedValue(mockListResponse(entries, "", 2)); + + const { result } = renderHook(() => useActivity({ pageSize: 25 })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(activityClient.listActivities).toHaveBeenCalledWith( + expect.objectContaining({ pageSize: 25, pageToken: "" }), + ); + expect(result.current.activities).toHaveLength(2); + expect(result.current.totalCount).toBe(2); + expect(result.current.hasMore).toBe(false); + }); + + it("loadMore appends next page of results", async () => { + const page1 = [makeEntry("1")]; + const page2 = [makeEntry("2")]; + + vi.mocked(activityClient.listActivities) + .mockResolvedValueOnce(mockListResponse(page1, "token-2", 2)) + .mockResolvedValueOnce(mockListResponse(page2, "", 2)); + + const { result } = renderHook(() => useActivity({ pageSize: 1 })); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.activities).toHaveLength(1); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + result.current.loadMore(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + expect(activityClient.listActivities).toHaveBeenLastCalledWith(expect.objectContaining({ pageToken: "token-2" })); + expect(result.current.activities).toHaveLength(2); + expect(result.current.hasMore).toBe(false); + }); + + it("discards stale responses when a newer request starts", async () => { + let resolveFirst: (value: ReturnType) => void; + const firstPromise = new Promise>((r) => { + resolveFirst = r; + }); + + const staleEntries = [makeEntry("stale")]; + const freshEntries = [makeEntry("fresh")]; + + vi.mocked(activityClient.listActivities) + .mockReturnValueOnce(firstPromise as Promise) + .mockResolvedValueOnce(mockListResponse(freshEntries, "", 1)); + + const filter1 = create(ActivityFilterSchema, { searchText: "old" }); + const filter2 = create(ActivityFilterSchema, { searchText: "new" }); + + const { result, rerender } = renderHook(({ filter }) => useActivity({ filter }), { + initialProps: { filter: filter1 as ActivityFilter }, + }); + + // Trigger second fetch via filter change before first resolves + rerender({ filter: filter2 as ActivityFilter }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + }); + + // Now resolve the stale first request + resolveFirst!(mockListResponse(staleEntries, "", 99)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Fresh result wins, stale result discarded + expect(result.current.activities[0].eventId).toBe("fresh"); + expect(result.current.totalCount).toBe(1); + }); + + it("refresh resets state and re-fetches from page 1", async () => { + const initialEntries = [makeEntry("1")]; + const refreshedEntries = [makeEntry("refreshed")]; + + vi.mocked(activityClient.listActivities) + .mockResolvedValueOnce(mockListResponse(initialEntries, "tok", 10)) + .mockResolvedValueOnce(mockListResponse(refreshedEntries, "", 5)); + + const { result } = renderHook(() => useActivity({})); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.totalCount).toBe(10); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + expect(activityClient.listActivities).toHaveBeenLastCalledWith(expect.objectContaining({ pageToken: "" })); + expect(result.current.activities).toHaveLength(1); + expect(result.current.activities[0].eventId).toBe("refreshed"); + expect(result.current.totalCount).toBe(5); + expect(result.current.hasMore).toBe(false); + }); + + it("re-fetches when filter changes", async () => { + vi.mocked(activityClient.listActivities).mockResolvedValue(mockListResponse([], "", 0)); + + const filter1 = create(ActivityFilterSchema, { searchText: "alpha" }); + const filter2 = create(ActivityFilterSchema, { searchText: "beta" }); + + const { rerender } = renderHook(({ filter }) => useActivity({ filter }), { + initialProps: { filter: filter1 as ActivityFilter }, + }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(1); + }); + + rerender({ filter: filter2 as ActivityFilter }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(2); + }); + }); + + it("does not re-fetch when filter content is identical (deep equality)", async () => { + vi.mocked(activityClient.listActivities).mockResolvedValue(mockListResponse([], "", 0)); + + const filter1 = create(ActivityFilterSchema, { searchText: "same" }); + const filter2 = create(ActivityFilterSchema, { searchText: "same" }); + + const { rerender } = renderHook(({ filter }) => useActivity({ filter }), { + initialProps: { filter: filter1 as ActivityFilter }, + }); + + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(1); + }); + + rerender({ filter: filter2 as ActivityFilter }); + + // Should still be 1 call -- no re-fetch for identical content + await waitFor(() => { + expect(activityClient.listActivities).toHaveBeenCalledTimes(1); + }); + }); + + it("handles auth errors and sets error state", async () => { + const testError = new Error("network failure"); + vi.mocked(activityClient.listActivities).mockRejectedValue(testError); + + const { result } = renderHook(() => useActivity({})); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledWith(expect.objectContaining({ error: testError })); + expect(result.current.error).toBeTruthy(); + expect(result.current.activities).toHaveLength(0); + }); +}); diff --git a/client/src/protoFleet/api/useActivity.ts b/client/src/protoFleet/api/useActivity.ts new file mode 100644 index 000000000..87561eafc --- /dev/null +++ b/client/src/protoFleet/api/useActivity.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { equals } from "@bufbuild/protobuf"; +import { activityClient } from "@/protoFleet/api/clients"; +import { + type ActivityEntry, + type ActivityFilter, + ActivityFilterSchema, +} from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseActivityParams { + filter?: ActivityFilter; + pageSize?: number; +} + +interface UseActivityResult { + activities: ActivityEntry[]; + totalCount: number; + isLoading: boolean; + error: string | null; + hasMore: boolean; + loadMore: () => void; + refresh: () => void; +} + +export function useActivity({ filter, pageSize = 50 }: UseActivityParams): UseActivityResult { + const { handleAuthErrors } = useAuthErrors(); + + const [activities, setActivities] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [pageToken, setPageToken] = useState(""); + + const requestIdRef = useRef(0); + + const fetchActivities = useCallback( + async (currentFilter: ActivityFilter | undefined, token: string, append: boolean) => { + const requestId = ++requestIdRef.current; + setIsLoading(true); + isLoadingRef.current = true; + setError(null); + + try { + const response = await activityClient.listActivities({ + filter: currentFilter, + pageSize, + pageToken: token, + }); + + if (requestId !== requestIdRef.current) return; + + const { activities: newActivities, nextPageToken, totalCount: responseTotalCount } = response; + + if (append) { + setActivities((prev) => [...prev, ...newActivities]); + } else { + setActivities(newActivities); + setTotalCount(responseTotalCount); + } + + setPageToken(nextPageToken); + pageTokenRef.current = nextPageToken; + setHasMore(nextPageToken !== ""); + hasMoreRef.current = nextPageToken !== ""; + } catch (err) { + if (requestId !== requestIdRef.current) return; + handleAuthErrors({ + error: err, + onError: (e) => { + setError(getErrorMessage(e, "Failed to load activities")); + }, + }); + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false); + isLoadingRef.current = false; + } + } + }, + [pageSize, handleAuthErrors], + ); + + // Ref-based stability (same pattern as useFleet.ts) + const fetchRef = useRef(fetchActivities); + useEffect(() => { + fetchRef.current = fetchActivities; + }, [fetchActivities]); + + const filterRef = useRef(filter); + useEffect(() => { + filterRef.current = filter; + }, [filter]); + + const pageTokenRef = useRef(pageToken); + useEffect(() => { + pageTokenRef.current = pageToken; + }, [pageToken]); + + const isLoadingRef = useRef(isLoading); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); + + const hasMoreRef = useRef(hasMore); + useEffect(() => { + hasMoreRef.current = hasMore; + }, [hasMore]); + + const loadMore = useCallback(() => { + if (hasMoreRef.current && !isLoadingRef.current) { + fetchRef.current(filterRef.current, pageTokenRef.current, true); + } + }, []); + + const refresh = useCallback(() => { + if (isLoadingRef.current) return; + setActivities([]); + setPageToken(""); + pageTokenRef.current = ""; + setHasMore(false); + hasMoreRef.current = false; + setTotalCount(0); + fetchRef.current(filterRef.current, "", false); + }, []); + + // Re-fetch when filter or pageSize changes (deep equality for filter) + const previousFilterRef = useRef(undefined); + const previousPageSizeRef = useRef(pageSize); + const hasLoadedRef = useRef(false); + + useEffect(() => { + const filtersEqual = + previousFilterRef.current === filter || + (previousFilterRef.current !== undefined && + filter !== undefined && + equals(ActivityFilterSchema, previousFilterRef.current, filter)); + const pageSizeChanged = previousPageSizeRef.current !== pageSize; + + if (hasLoadedRef.current && filtersEqual && !pageSizeChanged) return; + + previousFilterRef.current = filter; + previousPageSizeRef.current = pageSize; + hasLoadedRef.current = true; + + setActivities([]); + setPageToken(""); + pageTokenRef.current = ""; + setHasMore(false); + hasMoreRef.current = false; + setTotalCount(0); + + void fetchRef.current(filter, "", false); + }, [filter, pageSize]); + + return { activities, totalCount, isLoading, error, hasMore, loadMore, refresh }; +} diff --git a/client/src/protoFleet/api/useActivityFilterOptions.ts b/client/src/protoFleet/api/useActivityFilterOptions.ts new file mode 100644 index 000000000..dc96b8e6f --- /dev/null +++ b/client/src/protoFleet/api/useActivityFilterOptions.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from "react"; +import { activityClient } from "@/protoFleet/api/clients"; +import type { EventTypeOption, UserOption } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseActivityFilterOptionsResult { + eventTypes: EventTypeOption[]; + scopeTypes: string[]; + users: UserOption[]; + isLoading: boolean; + error: string | null; +} + +export function useActivityFilterOptions(): UseActivityFilterOptionsResult { + const { handleAuthErrors } = useAuthErrors(); + + const [eventTypes, setEventTypes] = useState([]); + const [scopeTypes, setScopeTypes] = useState([]); + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchFilterOptions = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await activityClient.listActivityFilterOptions({}); + setEventTypes(response.eventTypes); + setScopeTypes(response.scopeTypes); + setUsers(response.users); + } catch (error) { + handleAuthErrors({ + error, + onError: (err) => { + const message = err instanceof Error ? err.message : String(err); + setError(message); + }, + }); + } finally { + setIsLoading(false); + } + }, [handleAuthErrors]); + + useEffect(() => { + void fetchFilterOptions(); + }, [fetchFilterOptions]); + + return { eventTypes, scopeTypes, users, isLoading, error }; +} diff --git a/client/src/protoFleet/api/useApiKeys.ts b/client/src/protoFleet/api/useApiKeys.ts new file mode 100644 index 000000000..59df85953 --- /dev/null +++ b/client/src/protoFleet/api/useApiKeys.ts @@ -0,0 +1,120 @@ +import { useCallback } from "react"; + +import { apiKeyClient } from "@/protoFleet/api/clients"; +import type { ApiKeyInfo } from "@/protoFleet/api/generated/apikey/v1/apikey_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +export interface ApiKeyItem { + keyId: string; + name: string; + prefix: string; + createdAt: Date | null; + expiresAt: Date | null; + lastUsedAt: Date | null; + createdBy: string; +} + +interface CreateApiKeyProps { + name: string; + expiresAt?: Date; +} + +interface ListApiKeysProps { + onSuccess?: (keys: ApiKeyItem[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface RevokeApiKeyProps { + keyId: string; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +function toApiKeyItem(info: ApiKeyInfo): ApiKeyItem { + return { + keyId: info.keyId, + name: info.name, + prefix: info.prefix, + createdAt: info.createdAt && info.createdAt.seconds > 0 ? new Date(Number(info.createdAt.seconds) * 1000) : null, + expiresAt: info.expiresAt && info.expiresAt.seconds > 0 ? new Date(Number(info.expiresAt.seconds) * 1000) : null, + lastUsedAt: + info.lastUsedAt && info.lastUsedAt.seconds > 0 ? new Date(Number(info.lastUsedAt.seconds) * 1000) : null, + createdBy: info.createdBy, + }; +} + +const useApiKeys = () => { + const { handleAuthErrors } = useAuthErrors(); + + const createApiKey = useCallback( + async ({ name, expiresAt }: CreateApiKeyProps): Promise => { + try { + const response = await apiKeyClient.createApiKey({ + name, + expiresAt: expiresAt ? { seconds: BigInt(Math.floor(expiresAt.getTime() / 1000)), nanos: 0 } : undefined, + }); + + if (!response.info) { + throw new Error("Received an unexpected response from the server. Please try again."); + } + + return response.apiKey; + } catch (err) { + handleAuthErrors({ error: err }); + throw err instanceof Error ? err : new Error(String(err)); + } + }, + [handleAuthErrors], + ); + + const listApiKeys = useCallback( + async ({ onSuccess, onError, onFinally }: ListApiKeysProps) => { + await apiKeyClient + .listApiKeys({}) + .then((response) => { + onSuccess?.(response.apiKeys.map(toApiKeyItem)); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const revokeApiKey = useCallback( + async ({ keyId, onSuccess, onError, onFinally }: RevokeApiKeyProps) => { + await apiKeyClient + .revokeApiKey({ keyId }) + .then(() => { + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + return { createApiKey, listApiKeys, revokeApiKey }; +}; + +export { useApiKeys }; diff --git a/client/src/protoFleet/api/useAuth.ts b/client/src/protoFleet/api/useAuth.ts new file mode 100644 index 000000000..0f04997be --- /dev/null +++ b/client/src/protoFleet/api/useAuth.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useState } from "react"; + +import { authClient, onboardingClient } from "@/protoFleet/api/clients"; +import { UpdatePasswordRequest, UpdateUsernameRequest } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { CreateAdminLoginRequest } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors, useSetUsername } from "@/protoFleet/store"; + +interface SetPasswordProps { + onError?: (message: string) => void; + onFinally?: () => void; + onSuccess?: () => void; + setPasswordRequest: CreateAdminLoginRequest; +} +interface UpdatePasswordProps { + onError?: (message: string) => void; + onFinally?: () => void; + onSuccess?: () => void; + currentPassword: UpdatePasswordRequest["currentPassword"]; + newPassword: UpdatePasswordRequest["newPassword"]; +} + +interface UpdateUsernameProps { + username: UpdateUsernameRequest["username"]; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +const useAuth = () => { + const setUsername = useSetUsername(); + const { handleAuthErrors } = useAuthErrors(); + const [passwordLastUpdatedAt, setPasswordLastUpdatedAt] = useState(null); + + const setPassword = useCallback( + async ({ setPasswordRequest, onSuccess, onError, onFinally }: SetPasswordProps) => { + await onboardingClient + .createAdminLogin(setPasswordRequest) + .then(() => { + setUsername(setPasswordRequest.username); + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [setUsername, handleAuthErrors], + ); + + const fetchLastUpdatedPasswordDate = useCallback(async () => { + try { + const response = await authClient.getUserAuditInfo({}); + + if (response.info?.passwordUpdatedAt && response.info.passwordUpdatedAt.seconds > 0) { + const seconds = Number(response.info.passwordUpdatedAt.seconds); + const date = new Date(seconds * 1000); + setPasswordLastUpdatedAt(date); + } else { + setPasswordLastUpdatedAt(null); + } + } catch (error) { + handleAuthErrors({ + error, + onError: () => { + console.error("Error fetching last updated password date:", error); + }, + }); + } + }, [handleAuthErrors]); + + const updatePassword = useCallback( + async ({ currentPassword, newPassword, onSuccess, onError, onFinally }: UpdatePasswordProps) => { + await authClient + .updatePassword({ currentPassword, newPassword }) + .then(() => { + onSuccess?.(); + fetchLastUpdatedPasswordDate(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [fetchLastUpdatedPasswordDate, handleAuthErrors], + ); + + const updateUsername = useCallback( + async ({ username, onSuccess, onError, onFinally }: UpdateUsernameProps) => { + await authClient + .updateUsername({ username }) + .then(() => { + setUsername(username); + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [setUsername, handleAuthErrors], + ); + + useEffect(() => { + fetchLastUpdatedPasswordDate(); + }, [fetchLastUpdatedPasswordDate]); + + return { + setPassword, + updatePassword, + updateUsername, + passwordLastUpdatedAt, + }; +}; + +export { useAuth }; diff --git a/client/src/protoFleet/api/useAuthNeededMiners.test.ts b/client/src/protoFleet/api/useAuthNeededMiners.test.ts new file mode 100644 index 000000000..392befade --- /dev/null +++ b/client/src/protoFleet/api/useAuthNeededMiners.test.ts @@ -0,0 +1,143 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import useAuthNeededMiners from "./useAuthNeededMiners"; +import useFleet from "./useFleet"; +import { + MinerStateSnapshotSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./useFleet"); + +describe("useAuthNeededMiners", () => { + const mockUseFleetReturn = { + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + loadMore: vi.fn(), + currentPage: 0, + hasPreviousPage: false, + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + refetch: vi.fn(), + refreshCurrentPage: vi.fn(), + updateMinerWorkerName: vi.fn(), + availableModels: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useFleet).mockReturnValue(mockUseFleetReturn); + }); + + it("calls useFleet with default options", () => { + renderHook(() => useAuthNeededMiners()); + + expect(useFleet).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + + pageSize: 100, + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }), + ); + }); + + it("calls useFleet with custom pageSize", () => { + renderHook(() => useAuthNeededMiners({ pageSize: 50 })); + + expect(useFleet).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + + pageSize: 50, + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }), + ); + }); + + it("returns the same result as useFleet", () => { + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current).toEqual(mockUseFleetReturn); + }); + + it("filters for AUTHENTICATION_NEEDED pairing status only", () => { + renderHook(() => useAuthNeededMiners()); + + const callArgs = vi.mocked(useFleet).mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.pairingStatuses).toEqual([PairingStatus.AUTHENTICATION_NEEDED]); + }); + + describe("pagination", () => { + it("exposes hasMore flag for pagination", () => { + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + hasMore: true, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.hasMore).toBe(true); + }); + + it("exposes isLoading flag", () => { + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + isLoading: true, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.isLoading).toBe(true); + }); + + it("exposes loadMore function for pagination", () => { + const mockLoadMore = vi.fn(); + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + loadMore: mockLoadMore, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.loadMore).toBe(mockLoadMore); + expect(typeof result.current.loadMore).toBe("function"); + }); + + it("exposes totalMiners count", () => { + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + totalMiners: 42, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.totalMiners).toBe(42); + }); + + it("exposes miners map for local scope", () => { + const mockMiners = { + "miner-1": create(MinerStateSnapshotSchema, { + deviceIdentifier: "miner-1", + }), + "miner-2": create(MinerStateSnapshotSchema, { + deviceIdentifier: "miner-2", + }), + }; + vi.mocked(useFleet).mockReturnValue({ + ...mockUseFleetReturn, + miners: mockMiners, + }); + + const { result } = renderHook(() => useAuthNeededMiners()); + + expect(result.current.miners).toEqual(mockMiners); + }); + }); +}); diff --git a/client/src/protoFleet/api/useAuthNeededMiners.ts b/client/src/protoFleet/api/useAuthNeededMiners.ts new file mode 100644 index 000000000..d478400a6 --- /dev/null +++ b/client/src/protoFleet/api/useAuthNeededMiners.ts @@ -0,0 +1,77 @@ +import useFleet from "./useFleet"; +import { MinerStateSnapshot, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { MinerListFilter } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +type UseAuthNeededMinersOptions = { + pageSize?: number; + filter?: MinerListFilter; + enabled?: boolean; +}; + +type UseAuthNeededMinersReturn = { + /** Array of miner device identifiers */ + minerIds: string[]; + /** Map of miner device identifier to miner state snapshot (only for local scope) */ + miners: Record; + /** Total number of miners matching the filter */ + totalMiners: number; + /** Whether there are more miners to load */ + hasMore: boolean; + /** Whether the hook is currently loading data */ + isLoading: boolean; + /** Whether the initial load has completed */ + hasInitialLoadCompleted: boolean; + /** Load the next page of miners */ + loadMore: () => void; + /** Refetch the miner list from the beginning */ + refetch: () => void; + /** Available models for filter dropdown */ + availableModels: string[]; +}; + +/** + * Hook for fetching miners that require authentication credentials. + * This is a convenience wrapper around useFleet that filters for devices + * with AUTHENTICATION_NEEDED pairing status. + * + * These are devices that have been discovered and require user credentials + * to complete the pairing process. + * + * Uses local scope to avoid conflicting with the main fleet view's global state. + * This allows CompleteSetup and AuthenticateMiners to fetch auth-needed miners + * without affecting the MinerList component's data. + * + * @param options - Configuration options for the hook + * @param options.pageSize - Number of devices to fetch per page (default: 100) + * @param options.filter - Optional filter to apply to the auth-needed miners (e.g., status, tags, etc.) + * @returns Object containing miner data and pagination controls + * + * @example + * ```tsx + * const { + * minerIds, + * miners, + * totalMiners, + * hasMore, + * isLoading, + * loadMore + * } = useAuthNeededMiners({ pageSize: 50 }); + * + * // Load more miners when user scrolls + * if (hasMore && !isLoading) { + * loadMore(); + * } + * ``` + */ +const useAuthNeededMiners = (options: UseAuthNeededMinersOptions = {}): UseAuthNeededMinersReturn => { + const { pageSize = 100, filter, enabled = true } = options; + + return useFleet({ + enabled, + pageSize, + filter, + pairingStatuses: [PairingStatus.AUTHENTICATION_NEEDED], + }) as UseAuthNeededMinersReturn; +}; + +export default useAuthNeededMiners; diff --git a/client/src/protoFleet/api/useComponentErrors.test.ts b/client/src/protoFleet/api/useComponentErrors.test.ts new file mode 100644 index 000000000..55df01acb --- /dev/null +++ b/client/src/protoFleet/api/useComponentErrors.test.ts @@ -0,0 +1,247 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { errorQueryClient } from "./clients"; +import { useComponentErrors } from "./useComponentErrors"; +import { + ComponentErrorSchema, + ComponentErrorsSchema, + ComponentType, + ErrorMessageSchema, + QueryResponseSchema, +} from "@/protoFleet/api/generated/errors/v1/errors_pb"; + +vi.mock("./clients", () => ({ + errorQueryClient: { + query: vi.fn(), + }, +})); + +vi.mock("@/protoFleet/store", () => ({ + useFleetStore: vi.fn((selector) => + selector({ + auth: { authLoading: false }, + }), + ), + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: vi.fn(({ onError }) => onError), + })), +})); + +describe("useComponentErrors", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock for query - empty response + vi.mocked(errorQueryClient.query).mockResolvedValue( + create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { items: [] }), + }, + }), + ); + }); + + describe("device counting logic", () => { + it("counts unique devices, not component instances (THE BUG FIX)", async () => { + // Device A has 3 fans with errors (fan_0, fan_1, fan_2) + // This should count as 1 device, not 3 + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { + items: [ + create(ComponentErrorSchema, { + componentId: "device-a_fan_0", + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-1" })], + }), + create(ComponentErrorSchema, { + componentId: "device-a_fan_1", + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-2" })], + }), + create(ComponentErrorSchema, { + componentId: "device-a_fan_2", + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-3" })], + }), + ], + }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + // Should count 1 device with fan errors, not 3 + expect(result.current.fanErrors).toBe(1); + }); + + it("counts each unique device separately", async () => { + // 3 different devices, each with 1 fan error + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { + items: [ + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-1" })], + }), + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-b", + errors: [create(ErrorMessageSchema, { errorId: "err-2" })], + }), + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-c", + errors: [create(ErrorMessageSchema, { errorId: "err-3" })], + }), + ], + }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.fanErrors).toBe(3); + }); + + it("handles mix of devices with multiple components correctly (regression test)", async () => { + // Device A: fan_0, fan_1, fan_2, fan_3 (4 fans) + // Device B: fan_0, fan_1, fan_2 (3 fans) + // Device C: fan_0, fan_1, fan_2, fan_3 (4 fans) + // Total: 11 component entries, but only 3 unique devices + const items = [ + // Device A - 4 fans + ...["fan_0", "fan_1", "fan_2", "fan_3"].map((fan, i) => + create(ComponentErrorSchema, { + componentId: `device-a_${fan}`, + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: `err-a-${i}` })], + }), + ), + // Device B - 3 fans + ...["fan_0", "fan_1", "fan_2"].map((fan, i) => + create(ComponentErrorSchema, { + componentId: `device-b_${fan}`, + componentType: ComponentType.FAN, + deviceIdentifier: "device-b", + errors: [create(ErrorMessageSchema, { errorId: `err-b-${i}` })], + }), + ), + // Device C - 4 fans + ...["fan_0", "fan_1", "fan_2", "fan_3"].map((fan, i) => + create(ComponentErrorSchema, { + componentId: `device-c_${fan}`, + componentType: ComponentType.FAN, + deviceIdentifier: "device-c", + errors: [create(ErrorMessageSchema, { errorId: `err-c-${i}` })], + }), + ), + ]; + + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { items }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + // Should count 3 devices, not 11 component instances + expect(result.current.fanErrors).toBe(3); + }); + + it("tracks each component type independently", async () => { + // Device A has both fan and hashboard errors + const mockResponse = create(QueryResponseSchema, { + result: { + case: "components", + value: create(ComponentErrorsSchema, { + items: [ + create(ComponentErrorSchema, { + componentType: ComponentType.FAN, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-fan" })], + }), + create(ComponentErrorSchema, { + componentType: ComponentType.HASH_BOARD, + deviceIdentifier: "device-a", + errors: [create(ErrorMessageSchema, { errorId: "err-hb" })], + }), + ], + }), + }, + }); + + vi.mocked(errorQueryClient.query).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.fanErrors).toBe(1); + expect(result.current.hashboardErrors).toBe(1); + }); + }); + + describe("hook behavior", () => { + it("returns zero counts for empty response", async () => { + const { result } = renderHook(() => useComponentErrors()); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.fanErrors).toBe(0); + expect(result.current.hashboardErrors).toBe(0); + expect(result.current.psuErrors).toBe(0); + expect(result.current.controlBoardErrors).toBe(0); + }); + + it("returns isLoading true initially", () => { + const { result } = renderHook(() => useComponentErrors()); + + expect(result.current.isLoading).toBe(true); + }); + + it("sets hasLoaded after successful fetch", async () => { + const { result } = renderHook(() => useComponentErrors()); + + expect(result.current.hasLoaded).toBe(false); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + }); + }); +}); diff --git a/client/src/protoFleet/api/useComponentErrors.ts b/client/src/protoFleet/api/useComponentErrors.ts new file mode 100644 index 000000000..6421ed64f --- /dev/null +++ b/client/src/protoFleet/api/useComponentErrors.ts @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { errorQueryClient } from "@/protoFleet/api/clients"; +import { + type ComponentError, + ComponentType, + QueryRequestSchema, + ResultView, + type Summary, +} from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { useAuthErrors, useFleetStore } from "@/protoFleet/store"; + +interface ComponentErrorCounts { + controlBoardErrors: number; + fanErrors: number; + hashboardErrors: number; + psuErrors: number; +} + +interface UseComponentErrorsReturn extends ComponentErrorCounts { + isLoading: boolean; + hasLoaded: boolean; + error: Error | null; + refetch: () => Promise; +} + +interface UseComponentErrorsOptions { + /** Optional device identifiers to scope errors to specific devices (e.g., a group's members) */ + deviceIdentifiers?: string[]; + /** Optional polling interval in milliseconds */ + pollIntervalMs?: number; +} + +/** + * Hook to fetch component error counts. + * Manages its own local state — no dashboard store dependency. + * Supports optional polling for periodic refresh. + */ +export const useComponentErrors = (options?: UseComponentErrorsOptions): UseComponentErrorsReturn => { + const deviceIdentifiers = options?.deviceIdentifiers; + const isEmptyScope = deviceIdentifiers !== undefined && deviceIdentifiers.length === 0; + const deviceIdentifiersKey = deviceIdentifiers === undefined ? "__undefined__" : deviceIdentifiers.join(","); + + const authLoading = useFleetStore((state) => state.auth.authLoading); + const { handleAuthErrors } = useAuthErrors(); + + // Ref so fetchComponentErrors reads latest deviceIdentifiers without needing it as a dependency + const deviceIdentifiersRef = useRef(deviceIdentifiers); + deviceIdentifiersRef.current = deviceIdentifiers; + + // Local state for error counts + const [counts, setCounts] = useState>>({}); + const [isLoading, setIsLoading] = useState(true); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + + const requestIdRef = useRef(0); + const hasLoadedRef = useRef(false); + + // Reset on scope change — invalidate in-flight requests so stale responses can't land + const prevScopeRef = useRef(deviceIdentifiersKey); + if (prevScopeRef.current !== deviceIdentifiersKey) { + prevScopeRef.current = deviceIdentifiersKey; + ++requestIdRef.current; + hasLoadedRef.current = false; + setHasLoaded(false); + setCounts({}); + } + + const errorCounts: ComponentErrorCounts = { + controlBoardErrors: counts[ComponentType.CONTROL_BOARD] || 0, + fanErrors: counts[ComponentType.FAN] || 0, + hashboardErrors: counts[ComponentType.HASH_BOARD] || 0, + psuErrors: counts[ComponentType.PSU] || 0, + }; + + const fetchComponentErrors = useCallback(async () => { + if (isEmptyScope) { + ++requestIdRef.current; + setCounts({}); + setIsLoading(false); + return; + } + + const thisRequestId = ++requestIdRef.current; + + if (!hasLoadedRef.current) { + setIsLoading(true); + } + setError(null); + + try { + const currentDeviceIdentifiers = deviceIdentifiersRef.current; + const request = create(QueryRequestSchema, { + resultView: ResultView.COMPONENT, + filter: { + simple: { + ...(currentDeviceIdentifiers && + currentDeviceIdentifiers.length > 0 && { deviceIdentifiers: currentDeviceIdentifiers }), + }, + includeClosed: false, + }, + pageSize: 1000, + }); + + const response = await errorQueryClient.query(request); + + if (thisRequestId !== requestIdRef.current) return; + + if (response.result?.case === "components" && response.result.value) { + const newCounts = processComponentErrorCounts(response.result.value.items); + setCounts(newCounts); + } else { + setCounts({}); + } + hasLoadedRef.current = true; + setHasLoaded(true); + } catch (err) { + if (thisRequestId !== requestIdRef.current) return; + handleAuthErrors({ + error: err, + onError: (error) => { + console.error("Error fetching component errors:", error); + setError(error instanceof Error ? error : new Error("Failed to fetch component errors")); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, [handleAuthErrors, isEmptyScope]); + + // Initial fetch + refetch on scope change + useEffect(() => { + if (authLoading) return; + fetchComponentErrors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authLoading, deviceIdentifiersKey]); + + // Polling + useEffect(() => { + if (!options?.pollIntervalMs || authLoading) return; + + const intervalId = setInterval(() => { + void fetchComponentErrors(); + }, options.pollIntervalMs); + + return () => clearInterval(intervalId); + }, [options?.pollIntervalMs, authLoading, fetchComponentErrors]); + + return { + ...errorCounts, + isLoading, + hasLoaded, + error, + refetch: fetchComponentErrors, + }; +}; + +/** Count unique devices per component type from query response */ +function processComponentErrorCounts(components: ComponentError[]): Partial> { + const devicesByComponentType: Partial>> = {}; + + components.forEach((component) => { + if ( + component.componentType !== undefined && + component.deviceIdentifier && + component.errors && + component.errors.length > 0 + ) { + if (!devicesByComponentType[component.componentType]) { + devicesByComponentType[component.componentType] = new Set(); + } + devicesByComponentType[component.componentType]!.add(component.deviceIdentifier); + } + }); + + const counts: Partial> = {}; + Object.entries(devicesByComponentType).forEach(([type, devices]) => { + counts[Number(type) as ComponentType] = devices.size; + }); + return counts; +} + +// Additional types and hook for fetching specific component error details +interface ComponentErrorDetailResult { + summary?: Summary; + componentError?: ComponentError; + loading: boolean; + errorMessage?: string; +} + +/** + * Hook to fetch a specific component's errors and summary from the errors API. + * This is used when navigating to a component view in the StatusModal. + * @param deviceIdentifier - UUID of the device + * @param componentId - Full component ID (e.g., "1_hashboard_0") + * @param enabled - Whether to fetch (default true) + */ +export const useComponentErrorDetail = ( + deviceIdentifier: string | undefined, + componentId: string | undefined, + enabled = true, +): ComponentErrorDetailResult => { + const [result, setResult] = useState({ + loading: false, + }); + + const { handleAuthErrors } = useAuthErrors(); + + useEffect(() => { + if (!deviceIdentifier || !componentId || !enabled) { + return; + } + + const fetchComponentDetail = async () => { + setResult((prev) => ({ ...prev, loading: true })); + + try { + // Create query request for component view + const request = create(QueryRequestSchema, { + resultView: ResultView.COMPONENT, + filter: { + simple: { + deviceIdentifiers: [deviceIdentifier], + componentIds: [componentId], + }, + }, + pageSize: 100, // Increase to ensure we get all components + }); + + const response = await errorQueryClient.query(request); + + // Extract component error from response + if (response.result?.case === "components" && response.result.value?.items?.length > 0) { + const componentError = response.result.value.items[0]; + + setResult({ + summary: componentError.summary, + componentError, + loading: false, + }); + } else { + setResult({ + summary: undefined, + componentError: undefined, + loading: false, + }); + } + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + console.error("Failed to fetch component error detail:", error); + setResult({ + loading: false, + errorMessage: error instanceof Error ? error.message : "Failed to fetch component errors", + }); + }, + }); + } + }; + + fetchComponentDetail(); + }, [deviceIdentifier, componentId, enabled, handleAuthErrors]); + + return result; +}; diff --git a/client/src/protoFleet/api/useDeviceErrors.ts b/client/src/protoFleet/api/useDeviceErrors.ts new file mode 100644 index 000000000..810edf185 --- /dev/null +++ b/client/src/protoFleet/api/useDeviceErrors.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { errorQueryClient } from "@/protoFleet/api/clients"; +import { + type DeviceError, + type ErrorMessage, + QueryRequestSchema, + ResultView, +} from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseDeviceErrorsReturn { + errorsByDevice: Record; + isLoading: boolean; + /** True once at least one successful fetch has completed. */ + hasLoaded: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Hook to fetch device errors for a list of miner IDs. + * Returns errors grouped by device ID. All state is local to this hook. + */ +export const useDeviceErrors = (deviceIds: string[]): UseDeviceErrorsReturn => { + const { handleAuthErrors } = useAuthErrors(); + const [errorsByDevice, setErrorsByDevice] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + // Ref mirror of hasLoaded — used inside fetchDeviceErrors to gate isLoading + const hasLoadedRef = useRef(false); + + // Keep a ref to deviceIds so refetch() always uses the latest value + const deviceIdsRef = useRef(deviceIds); + deviceIdsRef.current = deviceIds; + + // Request sequencing — ignore responses from stale requests + const requestIdRef = useRef(0); + + const fetchDeviceErrors = useCallback( + async (ids: string[]) => { + // Bump counter before any early return so in-flight requests for the + // previous device set are discarded when they resolve. + const thisRequestId = ++requestIdRef.current; + + if (!ids || ids.length === 0) { + setErrorsByDevice({}); + setIsLoading(false); + return; + } + + // Only show loading state on initial fetch, not on poll refreshes + if (!hasLoadedRef.current) { + setIsLoading(true); + } + setError(null); + + try { + const request = create(QueryRequestSchema, { + resultView: ResultView.DEVICE, + filter: { + simple: { + deviceIdentifiers: ids, + }, + includeClosed: false, + }, + pageSize: 1000, + }); + + const response = await errorQueryClient.query(request); + + // Discard if a newer request has been issued since this one started + if (thisRequestId !== requestIdRef.current) return; + + const byDevice: Record = {}; + + if (response.result?.case === "devices" && response.result.value) { + const deviceErrors = response.result.value.items; + + deviceErrors.forEach((deviceError: DeviceError) => { + const deviceId = deviceError.deviceIdentifier; + if (deviceId && deviceError.errors) { + byDevice[deviceId] = [...deviceError.errors]; + } + }); + } + + // Only update state if error data actually changed — avoids unnecessary + // re-renders of MinerList/deviceItems on every poll when errors are unchanged. + setErrorsByDevice((prev) => { + const prevKeys = Object.keys(prev); + const nextKeys = Object.keys(byDevice); + if (prevKeys.length !== nextKeys.length) return byDevice; + for (const key of nextKeys) { + const prevErrors = prev[key]; + const nextErrors = byDevice[key]; + if (!prevErrors || prevErrors.length !== nextErrors.length) return byDevice; + // Compare error IDs to catch type/content changes at the same count + for (let i = 0; i < nextErrors.length; i++) { + if (prevErrors[i].errorId !== nextErrors[i].errorId) return byDevice; + } + } + return prev; + }); + + hasLoadedRef.current = true; + setHasLoaded(true); + } catch (err) { + // Discard errors from stale requests + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error: err, + onError: (error) => { + console.error("Error fetching device errors:", error); + setError(error instanceof Error ? error : new Error("Failed to fetch device errors")); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, + [handleAuthErrors], + ); + + // Track the previous deviceIds to detect meaningful changes (not just poll refreshes) + const prevDeviceIdsRef = useRef(deviceIds); + + // Fetch errors when device IDs change + useEffect(() => { + // Reset loading state when the device list actually changes (pagination/filter), + // but not on the same list (poll refreshes are handled by refetch which skips loading). + const prevIds = prevDeviceIdsRef.current; + const idsChanged = prevIds.length !== deviceIds.length || deviceIds.some((id, i) => id !== prevIds[i]); + if (idsChanged) { + hasLoadedRef.current = false; + setHasLoaded(false); + } + prevDeviceIdsRef.current = deviceIds; + + fetchDeviceErrors(deviceIds); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deviceIds]); + + // Stable refetch that uses the latest deviceIds + const refetch = useCallback(async () => { + await fetchDeviceErrors(deviceIdsRef.current); + }, [fetchDeviceErrors]); + + return { + errorsByDevice, + isLoading, + hasLoaded, + error, + refetch, + }; +}; diff --git a/client/src/protoFleet/api/useDeviceSetStateCounts.test.ts b/client/src/protoFleet/api/useDeviceSetStateCounts.test.ts new file mode 100644 index 000000000..a111056df --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSetStateCounts.test.ts @@ -0,0 +1,132 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deviceSetClient } from "./clients"; +import { useDeviceSetStateCounts } from "./useDeviceSetStateCounts"; + +vi.mock("./clients", () => ({ + deviceSetClient: { + getDeviceSetStats: vi.fn(), + }, +})); + +const { mockHandleAuthErrors } = vi.hoisted(() => ({ + mockHandleAuthErrors: vi.fn(({ onError }: { onError: (err: unknown) => void }) => onError), +})); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +const mockGetDeviceSetStats = vi.mocked(deviceSetClient.getDeviceSetStats); + +function createMockResponse( + deviceCount: number, + counts: { hashing?: number; broken?: number; offline?: number; sleeping?: number }, +) { + return { + stats: [ + { + deviceCount, + hashingCount: counts.hashing ?? 0, + brokenCount: counts.broken ?? 0, + offlineCount: counts.offline ?? 0, + sleepingCount: counts.sleeping ?? 0, + slotStatuses: [], + }, + ], + }; +} + +describe("useDeviceSetStateCounts", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns initial state when deviceSetId is undefined", () => { + const { result } = renderHook(() => useDeviceSetStateCounts({ deviceSetId: undefined })); + + expect(result.current.totalMiners).toBe(0); + expect(result.current.stateCounts).toBeUndefined(); + expect(result.current.hasLoaded).toBe(false); + expect(mockGetDeviceSetStats).not.toHaveBeenCalled(); + }); + + it("fetches counts when deviceSetId is provided", async () => { + mockGetDeviceSetStats.mockResolvedValue( + createMockResponse(42, { hashing: 30, broken: 5, offline: 4, sleeping: 3 }) as any, + ); + + const { result } = renderHook(() => useDeviceSetStateCounts({ deviceSetId: 1n })); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(result.current.totalMiners).toBe(42); + expect(result.current.stateCounts?.hashingCount).toBe(30); + expect(result.current.stateCounts?.brokenCount).toBe(5); + expect(result.current.stateCounts?.offlineCount).toBe(4); + expect(result.current.stateCounts?.sleepingCount).toBe(3); + }); + + it("resets state when deviceSetId changes", async () => { + mockGetDeviceSetStats.mockResolvedValue(createMockResponse(10, { hashing: 10 }) as any); + + const { result, rerender } = renderHook(({ deviceSetId }) => useDeviceSetStateCounts({ deviceSetId }), { + initialProps: { deviceSetId: 1n as bigint | undefined }, + }); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + expect(result.current.totalMiners).toBe(10); + + // Change deviceSetId — state should reset + mockGetDeviceSetStats.mockResolvedValue(createMockResponse(20, { hashing: 15, offline: 5 }) as any); + rerender({ deviceSetId: 2n }); + + // Before new fetch resolves, state should be cleared + expect(result.current.stats).toBeUndefined(); + expect(result.current.hasLoaded).toBe(false); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + expect(result.current.totalMiners).toBe(20); + expect(result.current.stateCounts?.offlineCount).toBe(5); + }); + + it("sets hasLoaded on error so consumers are not stuck loading", async () => { + mockGetDeviceSetStats.mockRejectedValue(new Error("network error")); + + const { result } = renderHook(() => useDeviceSetStateCounts({ deviceSetId: 1n })); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + // hasLoaded is true even on error — page can render with empty stats + expect(result.current.totalMiners).toBe(0); + expect(result.current.stateCounts).toBeUndefined(); + expect(result.current.isLoading).toBe(false); + }); + + it("does not fetch when deviceSetId transitions to undefined", async () => { + mockGetDeviceSetStats.mockResolvedValue(createMockResponse(10, { hashing: 10 }) as any); + + const { result, rerender } = renderHook(({ deviceSetId }) => useDeviceSetStateCounts({ deviceSetId }), { + initialProps: { deviceSetId: 1n as bigint | undefined }, + }); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + rerender({ deviceSetId: undefined }); + + // Should not have made a second call + expect(mockGetDeviceSetStats).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/protoFleet/api/useDeviceSetStateCounts.ts b/client/src/protoFleet/api/useDeviceSetStateCounts.ts new file mode 100644 index 000000000..9ac946afd --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSetStateCounts.ts @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { deviceSetClient } from "@/protoFleet/api/clients"; +import { type DeviceSetStats } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseDeviceSetStateCountsOptions { + deviceSetId: bigint | undefined; + pollIntervalMs?: number; +} + +interface StateCounts { + hashingCount: number; + brokenCount: number; + offlineCount: number; + sleepingCount: number; +} + +interface UseDeviceSetStateCountsReturn { + totalMiners: number; + stateCounts: StateCounts | undefined; + stats: DeviceSetStats | undefined; + isLoading: boolean; + hasLoaded: boolean; + refetch: () => void; +} + +export const useDeviceSetStateCounts = ({ + deviceSetId, + pollIntervalMs, +}: UseDeviceSetStateCountsOptions): UseDeviceSetStateCountsReturn => { + const { handleAuthErrors } = useAuthErrors(); + + const [stats, setStats] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + + const requestIdRef = useRef(0); + const hasLoadedRef = useRef(false); + + // Reset on deviceSetId change — invalidate in-flight requests so stale responses can't land + const prevIdRef = useRef(deviceSetId); + if (prevIdRef.current !== deviceSetId) { + prevIdRef.current = deviceSetId; + ++requestIdRef.current; + hasLoadedRef.current = false; + setHasLoaded(false); + setStats(undefined); + } + + const fetchStats = useCallback(async () => { + if (deviceSetId === undefined) { + ++requestIdRef.current; + setStats(undefined); + setIsLoading(false); + return; + } + + const thisRequestId = ++requestIdRef.current; + + if (!hasLoadedRef.current) { + setIsLoading(true); + } + + try { + const response = await deviceSetClient.getDeviceSetStats({ + deviceSetIds: [deviceSetId], + }); + + if (thisRequestId !== requestIdRef.current) return; + + const deviceSetStats = response.stats[0]; + setStats(deviceSetStats); + } catch (error) { + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error, + onError: (err) => { + console.error("Error fetching device set stats:", err); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + hasLoadedRef.current = true; + setHasLoaded(true); + } + } + }, [deviceSetId, handleAuthErrors]); + + // Initial fetch + refetch on deviceSetId change + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + // Polling + useEffect(() => { + if (!pollIntervalMs || deviceSetId === undefined) return; + + const intervalId = setInterval(() => { + void fetchStats(); + }, pollIntervalMs); + + return () => clearInterval(intervalId); + }, [pollIntervalMs, deviceSetId, fetchStats]); + + const stateCounts: StateCounts | undefined = useMemo( + () => + stats + ? { + hashingCount: stats.hashingCount, + brokenCount: stats.brokenCount, + offlineCount: stats.offlineCount, + sleepingCount: stats.sleepingCount, + } + : undefined, + [stats], + ); + + const totalMiners = stats?.deviceCount ?? 0; + + return { + totalMiners, + stateCounts, + stats, + isLoading, + hasLoaded, + refetch: fetchStats, + }; +}; diff --git a/client/src/protoFleet/api/useDeviceSets.test.ts b/client/src/protoFleet/api/useDeviceSets.test.ts new file mode 100644 index 000000000..c994153aa --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSets.test.ts @@ -0,0 +1,166 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Code, ConnectError } from "@connectrpc/connect"; + +const mockListDeviceSetMembers = vi.fn(); + +vi.mock("./clients", () => ({ + deviceSetClient: { + listDeviceSetMembers: (...args: unknown[]) => mockListDeviceSetMembers(...args), + }, +})); + +const mockHandleAuthErrors = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +// Import after mocks are set up +const { useDeviceSets } = await import("./useDeviceSets"); + +describe("useDeviceSets — listGroupMembers", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHandleAuthErrors.mockImplementation(({ onError }: { onError: () => void }) => onError()); + }); + + it("returns member IDs via onSuccess on normal completion", async () => { + mockListDeviceSetMembers.mockResolvedValueOnce({ + members: [{ deviceIdentifier: "d1" }, { deviceIdentifier: "d2" }], + nextPageToken: "", + }); + + const onSuccess = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onSuccess, + onFinally, + }); + }); + + expect(onSuccess).toHaveBeenCalledWith(["d1", "d2"]); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("does not call onError or handleAuthErrors when AbortError is thrown", async () => { + mockListDeviceSetMembers.mockRejectedValueOnce(new DOMException("aborted", "AbortError")); + + const onSuccess = vi.fn(); + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onSuccess, + onError, + onFinally, + }); + }); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(mockHandleAuthErrors).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("does not call onError when ConnectError with Canceled code is thrown after signal abort", async () => { + const controller = new AbortController(); + controller.abort(); + + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("canceled", Code.Canceled)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + signal: controller.signal, + onError, + onFinally, + }); + }); + + expect(onError).not.toHaveBeenCalled(); + expect(mockHandleAuthErrors).not.toHaveBeenCalled(); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("calls handleAuthErrors when ConnectError with Canceled code is thrown without an aborted signal", async () => { + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("canceled", Code.Canceled)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onError, + onFinally, + }); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("still calls handleAuthErrors for Unauthenticated error even if signal is aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("session expired", Code.Unauthenticated)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + signal: controller.signal, + onError, + onFinally, + }); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledTimes(1); + }); + + it("calls onError via handleAuthErrors for non-abort RPC errors", async () => { + mockListDeviceSetMembers.mockRejectedValueOnce(new ConnectError("internal error", Code.Internal)); + + const onError = vi.fn(); + const onFinally = vi.fn(); + + const { result } = renderHook(() => useDeviceSets()); + + await act(async () => { + await result.current.listGroupMembers({ + deviceSetId: 1n, + onError, + onFinally, + }); + }); + + expect(mockHandleAuthErrors).toHaveBeenCalledTimes(1); + expect(onFinally).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/protoFleet/api/useDeviceSets.ts b/client/src/protoFleet/api/useDeviceSets.ts new file mode 100644 index 000000000..62aa82fda --- /dev/null +++ b/client/src/protoFleet/api/useDeviceSets.ts @@ -0,0 +1,858 @@ +import { useCallback } from "react"; +import { create } from "@bufbuild/protobuf"; +import { Code, ConnectError } from "@connectrpc/connect"; + +import { deviceSetClient } from "@/protoFleet/api/clients"; +import { + DeviceIdentifierListSchema, + DeviceSelectorSchema, +} from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { type SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type DeviceSet, + type DeviceSetStats, + DeviceSetType, + type RackCoolingType, + RackInfoSchema, + type RackOrderIndex, + type RackSlot, + type RackSlotPosition, + RackSlotPositionSchema, + RackSlotSchema, + type RackType, +} from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface CreateGroupProps { + label: string; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface UpdateGroupProps { + deviceSetId: bigint; + label?: string; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface DeleteGroupProps { + deviceSetId: bigint; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListDeviceSetsProps { + pageSize?: number; + pageToken?: string; + sort?: SortConfig; + errorComponentTypes?: number[]; + zones?: string[]; + onSuccess?: (deviceSets: DeviceSet[], nextPageToken: string, totalCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface AddDevicesToDeviceSetProps { + deviceSetId: bigint; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (addedCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface GetDeviceSetProps { + deviceSetId: bigint; + onSuccess?: (deviceSet: DeviceSet) => void; + onNotFound?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface GetDeviceSetStatsProps { + deviceSetIds: bigint[]; + onSuccess?: (stats: DeviceSetStats[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface CreateRackProps { + label: string; + zone: string; + rows: number; + columns: number; + orderIndex: RackOrderIndex; + coolingType: RackCoolingType; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListRackZonesProps { + onSuccess?: (zones: string[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListRackTypesProps { + onSuccess?: (rackTypes: RackType[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListGroupMembersProps { + deviceSetId: bigint; + signal?: AbortSignal; + onSuccess?: (deviceIdentifiers: string[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface RemoveDevicesFromDeviceSetProps { + deviceSetId: bigint; + deviceIdentifiers?: string[]; + allDevices?: boolean; + onSuccess?: (removedCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface UpdateRackProps { + deviceSetId: bigint; + label?: string; + zone?: string; + rows?: number; + columns?: number; + orderIndex?: RackOrderIndex; + coolingType?: RackCoolingType; + onSuccess?: (deviceSet: DeviceSet) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface GetRackSlotsProps { + deviceSetId: bigint; + onSuccess?: (slots: RackSlot[]) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface SetRackSlotPositionProps { + deviceSetId: bigint; + deviceIdentifier: string; + position: RackSlotPosition; + onSuccess?: (slot: RackSlot) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ClearRackSlotPositionProps { + deviceSetId: bigint; + deviceIdentifier: string; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface SaveRackProps { + deviceSetId?: bigint; + label: string; + zone: string; + rows: number; + columns: number; + orderIndex: RackOrderIndex; + coolingType: RackCoolingType; + deviceIdentifiers: string[]; + allDevices?: boolean; + slotAssignments: { deviceIdentifier: string; row: number; column: number }[]; + onSuccess?: (deviceSet: DeviceSet, assignedCount: number) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +const memberPageSize = 250; + +function buildDeviceSelector(deviceIdentifiers: string[] | undefined, allDevices: boolean | undefined) { + if (allDevices) { + return create(DeviceSelectorSchema, { + selectionType: { + case: "allDevices", + value: true, + }, + }); + } + // When deviceIdentifiers is provided (even empty), build a device list selector + if (deviceIdentifiers !== undefined) { + return create(DeviceSelectorSchema, { + selectionType: { + case: "deviceList", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers, + }), + }, + }); + } + return undefined; +} + +function getDeviceSetErrorMessage(err: unknown, kind: "group" | "rack"): string { + if (err instanceof ConnectError && err.code === Code.AlreadyExists) { + return `A ${kind} with this name already exists`; + } + return getErrorMessage(err); +} + +const useDeviceSets = () => { + const { handleAuthErrors } = useAuthErrors(); + + const createGroup = useCallback( + async ({ label, deviceIdentifiers = [], allDevices = false, onSuccess, onError, onFinally }: CreateGroupProps) => { + try { + const deviceSelector = + allDevices || deviceIdentifiers.length > 0 ? buildDeviceSelector(deviceIdentifiers, allDevices) : undefined; + + const createResponse = await deviceSetClient.createDeviceSet({ + type: DeviceSetType.GROUP, + label, + deviceSelector, + }); + + const deviceSet = createResponse.deviceSet; + if (!deviceSet) { + onError?.("Failed to create group"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "group")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const updateGroup = useCallback( + async ({ deviceSetId, label, deviceIdentifiers, allDevices, onSuccess, onError, onFinally }: UpdateGroupProps) => { + try { + const deviceSelector = buildDeviceSelector(deviceIdentifiers, allDevices); + + const response = await deviceSetClient.updateDeviceSet({ + deviceSetId, + label, + deviceSelector, + }); + + const deviceSet = response.deviceSet; + if (!deviceSet) { + onError?.("Failed to update group"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "group")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const deleteGroup = useCallback( + async ({ deviceSetId, onSuccess, onError, onFinally }: DeleteGroupProps) => { + try { + await deviceSetClient.deleteDeviceSet({ deviceSetId }); + onSuccess?.(); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listGroups = useCallback( + async ({ pageSize, pageToken, sort, errorComponentTypes, onSuccess, onError, onFinally }: ListDeviceSetsProps) => { + try { + if (pageSize) { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.GROUP, + pageSize, + pageToken: pageToken ?? "", + sort, + errorComponentTypes: errorComponentTypes ?? [], + }); + onSuccess?.(response.deviceSets, response.nextPageToken, response.totalCount); + } else { + // Server caps pageSize at 1000, so we page through all results + // to support callers that need the full unpaginated list. + const all: DeviceSet[] = []; + let nextToken = ""; + do { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.GROUP, + pageSize: 1000, + pageToken: nextToken, + sort, + }); + all.push(...response.deviceSets); + nextToken = response.nextPageToken; + } while (nextToken); + onSuccess?.(all, "", all.length); + } + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listRacks = useCallback( + async ({ + pageSize, + pageToken, + sort, + errorComponentTypes, + zones, + onSuccess, + onError, + onFinally, + }: ListDeviceSetsProps) => { + try { + if (pageSize) { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.RACK, + pageSize, + pageToken: pageToken ?? "", + sort, + errorComponentTypes: errorComponentTypes ?? [], + zones: zones ?? [], + }); + onSuccess?.(response.deviceSets, response.nextPageToken, response.totalCount); + } else { + // Server caps pageSize at 1000, so we page through all results + // to support callers that need the full unpaginated list. + const all: DeviceSet[] = []; + let nextToken = ""; + do { + const response = await deviceSetClient.listDeviceSets({ + type: DeviceSetType.RACK, + pageSize: 1000, + pageToken: nextToken, + sort, + zones: zones ?? [], + }); + all.push(...response.deviceSets); + nextToken = response.nextPageToken; + } while (nextToken); + onSuccess?.(all, "", all.length); + } + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const getDeviceSet = useCallback( + async ({ deviceSetId, onSuccess, onNotFound, onError, onFinally }: GetDeviceSetProps) => { + try { + const response = await deviceSetClient.getDeviceSet({ deviceSetId }); + const deviceSet = response.deviceSet; + if (!deviceSet) { + onNotFound?.(); + return; + } + onSuccess?.(deviceSet); + } catch (err) { + if (err instanceof ConnectError && err.code === Code.NotFound) { + onNotFound?.(); + } else { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listGroupMembers = useCallback( + async ({ deviceSetId, signal, onSuccess, onError, onFinally }: ListGroupMembersProps) => { + try { + const allIdentifiers: string[] = []; + let pageToken = ""; + + do { + const response = await deviceSetClient.listDeviceSetMembers( + { + deviceSetId, + pageSize: memberPageSize, + pageToken, + }, + { signal }, + ); + for (const member of response.members) { + allIdentifiers.push(member.deviceIdentifier); + } + pageToken = response.nextPageToken; + } while (pageToken !== ""); + + onSuccess?.(allIdentifiers); + } catch (err) { + if ( + (err instanceof DOMException && err.name === "AbortError") || + (err instanceof ConnectError && err.code === Code.Canceled && signal?.aborted) + ) { + return; + } + + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const getDeviceSetStats = useCallback( + async ({ deviceSetIds, onSuccess, onError, onFinally }: GetDeviceSetStatsProps) => { + try { + const response = await deviceSetClient.getDeviceSetStats({ deviceSetIds }); + onSuccess?.(response.stats); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const addDevicesToDeviceSet = useCallback( + async ({ + deviceSetId, + deviceIdentifiers, + allDevices, + onSuccess, + onError, + onFinally, + }: AddDevicesToDeviceSetProps) => { + try { + const deviceSelector = + allDevices || (deviceIdentifiers && deviceIdentifiers.length > 0) + ? buildDeviceSelector(deviceIdentifiers, allDevices) + : undefined; + + const response = await deviceSetClient.addDevicesToDeviceSet({ + deviceSetId, + deviceSelector, + }); + + onSuccess?.(response.addedCount); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const createRack = useCallback( + async ({ label, zone, rows, columns, orderIndex, coolingType, onSuccess, onError, onFinally }: CreateRackProps) => { + try { + const rackInfo = create(RackInfoSchema, { + rows, + columns, + zone, + orderIndex, + coolingType, + }); + + const createResponse = await deviceSetClient.createDeviceSet({ + type: DeviceSetType.RACK, + label, + typeDetails: { + case: "rackInfo", + value: rackInfo, + }, + }); + + const deviceSet = createResponse.deviceSet; + if (!deviceSet) { + onError?.("Failed to create rack"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "rack")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listRackZones = useCallback( + async ({ onSuccess, onError, onFinally }: ListRackZonesProps) => { + try { + const response = await deviceSetClient.listRackZones({}); + onSuccess?.(response.zones); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const listRackTypes = useCallback( + async ({ onSuccess, onError, onFinally }: ListRackTypesProps) => { + try { + const response = await deviceSetClient.listRackTypes({}); + onSuccess?.(response.rackTypes); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const removeDevicesFromDeviceSet = useCallback( + async ({ + deviceSetId, + deviceIdentifiers, + allDevices, + onSuccess, + onError, + onFinally, + }: RemoveDevicesFromDeviceSetProps) => { + try { + const deviceSelector = + allDevices || (deviceIdentifiers && deviceIdentifiers.length > 0) + ? buildDeviceSelector(deviceIdentifiers, allDevices) + : undefined; + + const response = await deviceSetClient.removeDevicesFromDeviceSet({ + deviceSetId, + deviceSelector, + }); + + onSuccess?.(response.removedCount); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const updateRack = useCallback( + async ({ + deviceSetId, + label, + zone, + rows, + columns, + orderIndex, + coolingType, + onSuccess, + onError, + onFinally, + }: UpdateRackProps) => { + try { + const rackInfo = + zone !== undefined || + rows !== undefined || + columns !== undefined || + orderIndex !== undefined || + coolingType !== undefined + ? create(RackInfoSchema, { + ...(zone !== undefined && { zone }), + ...(rows !== undefined && { rows }), + ...(columns !== undefined && { columns }), + ...(orderIndex !== undefined && { orderIndex }), + ...(coolingType !== undefined && { coolingType }), + }) + : undefined; + + const response = await deviceSetClient.updateDeviceSet({ + deviceSetId, + label, + ...(rackInfo && { + typeDetails: { + case: "rackInfo" as const, + value: rackInfo, + }, + }), + }); + + const deviceSet = response.deviceSet; + if (!deviceSet) { + onError?.("Failed to update rack"); + return; + } + + onSuccess?.(deviceSet); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "rack")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const getRackSlots = useCallback( + async ({ deviceSetId, onSuccess, onError, onFinally }: GetRackSlotsProps) => { + try { + const response = await deviceSetClient.getRackSlots({ deviceSetId }); + onSuccess?.(response.slots); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const setRackSlotPosition = useCallback( + async ({ deviceSetId, deviceIdentifier, position, onSuccess, onError, onFinally }: SetRackSlotPositionProps) => { + try { + const response = await deviceSetClient.setRackSlotPosition({ + deviceSetId, + deviceIdentifier, + position, + }); + + const slot = response.slot; + if (!slot) { + onError?.("Failed to set slot position"); + return; + } + + onSuccess?.(slot); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const clearRackSlotPosition = useCallback( + async ({ deviceSetId, deviceIdentifier, onSuccess, onError, onFinally }: ClearRackSlotPositionProps) => { + try { + await deviceSetClient.clearRackSlotPosition({ + deviceSetId, + deviceIdentifier, + }); + onSuccess?.(); + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + const saveRack = useCallback( + async ({ + deviceSetId, + label, + zone, + rows, + columns, + orderIndex, + coolingType, + deviceIdentifiers, + allDevices, + slotAssignments, + onSuccess, + onError, + onFinally, + }: SaveRackProps) => { + try { + const rackInfo = create(RackInfoSchema, { + rows, + columns, + zone, + orderIndex, + coolingType, + }); + + const deviceSelector = buildDeviceSelector(deviceIdentifiers, allDevices); + + const rackSlots = slotAssignments.map((sa) => + create(RackSlotSchema, { + deviceIdentifier: sa.deviceIdentifier, + position: create(RackSlotPositionSchema, { + row: sa.row, + column: sa.column, + }), + }), + ); + + const response = await deviceSetClient.saveRack({ + deviceSetId, + label, + rackInfo, + deviceSelector, + slotAssignments: rackSlots, + }); + + const deviceSet = response.deviceSet; + if (!deviceSet) { + onError?.("Failed to save rack"); + return; + } + + onSuccess?.(deviceSet, response.assignedCount); + } catch (err) { + handleAuthErrors({ + error: err, + onError: (error) => { + onError?.(getDeviceSetErrorMessage(error, "rack")); + }, + }); + } finally { + onFinally?.(); + } + }, + [handleAuthErrors], + ); + + return { + createGroup, + createRack, + updateGroup, + updateRack, + deleteGroup, + getDeviceSet, + listGroups, + listRacks, + listRackZones, + listRackTypes, + listGroupMembers, + getDeviceSetStats, + addDevicesToDeviceSet, + removeDevicesFromDeviceSet, + getRackSlots, + setRackSlotPosition, + clearRackSlotPosition, + saveRack, + }; +}; + +export { useDeviceSets }; +export type { ListDeviceSetsProps }; diff --git a/client/src/protoFleet/api/useExportActivity.ts b/client/src/protoFleet/api/useExportActivity.ts new file mode 100644 index 000000000..d91886c9a --- /dev/null +++ b/client/src/protoFleet/api/useExportActivity.ts @@ -0,0 +1,59 @@ +import { useCallback, useRef, useState } from "react"; +import { activityClient } from "@/protoFleet/api/clients"; +import type { ActivityFilter } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { useAuthErrors } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; +import { downloadBlob, getFileName } from "@/shared/utils/utility"; + +const MIN_EXPORT_LOADING_MS = 400; + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +export function useExportActivity() { + const [isExporting, setIsExporting] = useState(false); + const isExportingRef = useRef(false); + const { handleAuthErrors } = useAuthErrors(); + + const handleExportCsv = useCallback( + async (filter?: ActivityFilter) => { + if (isExportingRef.current) return; + + const startedAt = Date.now(); + isExportingRef.current = true; + setIsExporting(true); + + try { + const chunks: Uint8Array[] = []; + + for await (const chunk of activityClient.exportActivities({ filter })) { + chunks.push(new Uint8Array(chunk.chunk)); + } + + const blob = new Blob(chunks, { type: "text/csv;charset=utf-8;" }); + downloadBlob(blob, getFileName("activity-export")); + } catch (error) { + handleAuthErrors({ + error, + onError: (err) => { + console.error("Error exporting activities:", err); + pushToast({ + status: TOAST_STATUSES.error, + message: "Failed to export activities. Please try again.", + }); + }, + }); + } finally { + const elapsedMs = Date.now() - startedAt; + const remainingMs = MIN_EXPORT_LOADING_MS - elapsedMs; + if (remainingMs > 0) { + await sleep(remainingMs); + } + isExportingRef.current = false; + setIsExporting(false); + } + }, + [handleAuthErrors], + ); + + return { exportCsv: handleExportCsv, isExportingCsv: isExporting }; +} diff --git a/client/src/protoFleet/api/useExportMinerListCsv.ts b/client/src/protoFleet/api/useExportMinerListCsv.ts new file mode 100644 index 000000000..e7fbbaea2 --- /dev/null +++ b/client/src/protoFleet/api/useExportMinerListCsv.ts @@ -0,0 +1,78 @@ +import { useCallback, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { + CsvTemperatureUnit, + ExportMinerListCsvRequestSchema, + type MinerListFilter, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors, useTemperatureUnit } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; +import { downloadBlob, getFileName } from "@/shared/utils/utility"; + +type UseExportMinerListCsvOptions = { + filter?: MinerListFilter; +}; + +const MIN_EXPORT_LOADING_MS = 400; + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)); + +const useExportMinerListCsv = ({ filter }: UseExportMinerListCsvOptions) => { + const [isExporting, setIsExporting] = useState(false); + const isExportingRef = useRef(false); + const temperatureUnit = useTemperatureUnit(); + const { handleAuthErrors } = useAuthErrors(); + + const handleExportCsv = useCallback(async () => { + if (isExportingRef.current) { + return; + } + + const startedAt = Date.now(); + isExportingRef.current = true; + setIsExporting(true); + + try { + const chunks: Uint8Array[] = []; + + for await (const chunk of fleetManagementClient.exportMinerListCsv( + create(ExportMinerListCsvRequestSchema, { + filter, + temperatureUnit: temperatureUnit === "F" ? CsvTemperatureUnit.FAHRENHEIT : CsvTemperatureUnit.CELSIUS, + }), + )) { + chunks.push(new Uint8Array(chunk.csvData)); + } + + const blob = new Blob(chunks, { type: "text/csv;charset=utf-8;" }); + downloadBlob(blob, getFileName("proto-fleet-miner-snapshot")); + } catch (error) { + handleAuthErrors({ + error, + onError: (err) => { + console.error("Error exporting miner list CSV:", err); + pushToast({ + status: TOAST_STATUSES.error, + message: "Failed to export miners. Please try again.", + }); + }, + }); + } finally { + const elapsedMs = Date.now() - startedAt; + const remainingMs = MIN_EXPORT_LOADING_MS - elapsedMs; + if (remainingMs > 0) { + await sleep(remainingMs); + } + isExportingRef.current = false; + setIsExporting(false); + } + }, [filter, handleAuthErrors, temperatureUnit]); + + return { + exportCsv: handleExportCsv, + isExportingCsv: isExporting, + }; +}; + +export default useExportMinerListCsv; diff --git a/client/src/protoFleet/api/useFileUpload.test.ts b/client/src/protoFleet/api/useFileUpload.test.ts new file mode 100644 index 000000000..a6d501120 --- /dev/null +++ b/client/src/protoFleet/api/useFileUpload.test.ts @@ -0,0 +1,254 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useFileUpload } from "./useFileUpload"; + +const mockLogout = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useLogout: () => mockLogout, +})); + +describe("useFileUpload", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("direct upload (XHR)", () => { + let xhrInstances: MockXHR[]; + + class MockXHR { + open = vi.fn(); + send = vi.fn(); + abort = vi.fn(); + withCredentials = false; + status = 0; + statusText = ""; + responseText = ""; + upload = { addEventListener: vi.fn() }; + private listeners: Record void)[]> = {}; + + constructor() { + xhrInstances.push(this); + } + + addEventListener(event: string, handler: () => void) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(handler); + } + + trigger(event: string) { + this.listeners[event]?.forEach((h) => h()); + } + } + + beforeEach(() => { + xhrInstances = []; + vi.stubGlobal("XMLHttpRequest", MockXHR); + }); + + it("sends multipart POST with credentials and resolves parsed JSON", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + const xhr = xhrInstances[0]; + expect(xhr.open).toHaveBeenCalledWith("POST", "/upload"); + expect(xhr.withCredentials).toBe(true); + expect(xhr.send).toHaveBeenCalledWith(expect.any(FormData)); + + xhr.status = 200; + xhr.responseText = JSON.stringify({ id: "abc" }); + xhr.trigger("load"); + + await expect(promise).resolves.toEqual({ id: "abc" }); + }); + + it("calls logout on 401", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + const xhr = xhrInstances[0]; + xhr.status = 401; + xhr.trigger("load"); + + await expect(promise).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("surfaces server error message from JSON body", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + const xhr = xhrInstances[0]; + xhr.status = 400; + xhr.responseText = JSON.stringify({ error: "bad input" }); + xhr.trigger("load"); + + await expect(promise).rejects.toThrow("bad input"); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it("reports progress via onProgress", async () => { + const file = new File(["data"], "file.swu"); + const onProgress = vi.fn(); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file, { onProgress }); + + const xhr = xhrInstances[0]; + expect(xhr.upload.addEventListener).toHaveBeenCalledWith("progress", expect.any(Function)); + + const handler = xhr.upload.addEventListener.mock.calls[0][1]; + handler({ lengthComputable: true, loaded: 25, total: 100 }); + expect(onProgress).toHaveBeenCalledWith(25); + + xhr.status = 200; + xhr.responseText = "{}"; + xhr.trigger("load"); + await promise; + }); + + it("aborts on signal", async () => { + const controller = new AbortController(); + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file, { signal: controller.signal }); + + controller.abort(); + xhrInstances[0].trigger("abort"); + + await expect(promise).rejects.toThrow("Upload was cancelled."); + }); + + it("rejects on network error", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const promise = result.current.upload("/upload", file); + + xhrInstances[0].trigger("error"); + + await expect(promise).rejects.toThrow("Network error during upload."); + }); + + it("uses custom fieldName", async () => { + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + result.current.upload("/upload", file, { fieldName: "firmware" }); + + const xhr = xhrInstances[0]; + const formData: FormData = xhr.send.mock.calls[0][0]; + expect(formData.get("firmware")).toBeTruthy(); + }); + }); + + describe("chunked upload (fetch)", () => { + function mockFetchSequence(...responses: Array<{ status: number; body?: object }>) { + const mocked = vi.fn(); + for (const { status, body } of responses) { + mocked.mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + json: () => Promise.resolve(body ?? {}), + }); + } + vi.stubGlobal("fetch", mocked); + return mocked; + } + + const chunkedConfig = { + enabled: true, + chunkSize: 5, + initiateUrl: "/upload/chunked", + chunkUrl: (id: string) => `/upload/chunked/${id}`, + completeUrl: (id: string) => `/upload/chunked/${id}/complete`, + }; + + it("uploads via initiate → PUT chunks → complete", async () => { + const mockFetch = mockFetchSequence( + { status: 200, body: { upload_id: "u1" } }, + { status: 200 }, + { status: 200 }, + { status: 200, body: { firmware_file_id: "fw-1" } }, + ); + + const file = new File(["a".repeat(10)], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + const data = await result.current.upload("/ignored", file, { chunked: chunkedConfig }); + + expect(data).toEqual({ firmware_file_id: "fw-1" }); + expect(mockFetch).toHaveBeenCalledTimes(4); + + expect(mockFetch.mock.calls[0][0]).toBe("/upload/chunked"); + expect(mockFetch.mock.calls[1][0]).toBe("/upload/chunked/u1"); + expect(mockFetch.mock.calls[2][0]).toBe("/upload/chunked/u1"); + expect(mockFetch.mock.calls[3][0]).toBe("/upload/chunked/u1/complete"); + }); + + it("calls logout on 401 during initiate", async () => { + mockFetchSequence({ status: 401 }); + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + await expect(result.current.upload("/x", file, { chunked: chunkedConfig })).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("calls logout on 401 during chunk upload", async () => { + mockFetchSequence({ status: 200, body: { upload_id: "u1" } }, { status: 401 }); + const file = new File(["a".repeat(10)], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + await expect(result.current.upload("/x", file, { chunked: chunkedConfig })).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("reports progress after each chunk", async () => { + mockFetchSequence( + { status: 200, body: { upload_id: "u1" } }, + { status: 200 }, + { status: 200 }, + { status: 200 }, + { status: 200, body: { result: "ok" } }, + ); + + const file = new File(["a".repeat(15)], "file.swu"); + const onProgress = vi.fn(); + const { result } = renderHook(() => useFileUpload()); + await result.current.upload("/x", file, { chunked: chunkedConfig, onProgress }); + + expect(onProgress).toHaveBeenCalledTimes(3); + expect(onProgress).toHaveBeenNthCalledWith(1, 33); + expect(onProgress).toHaveBeenNthCalledWith(2, 67); + expect(onProgress).toHaveBeenNthCalledWith(3, 100); + }); + + it("respects abort signal between chunks", async () => { + const controller = new AbortController(); + mockFetchSequence({ status: 200, body: { upload_id: "u1" } }, { status: 200 }); + + const file = new File(["a".repeat(15)], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + controller.abort(); + await expect( + result.current.upload("/x", file, { chunked: chunkedConfig, signal: controller.signal }), + ).rejects.toThrow(); + }); + + it("throws when initiate is missing upload_id", async () => { + mockFetchSequence({ status: 200, body: {} }); + const file = new File(["data"], "file.swu"); + const { result } = renderHook(() => useFileUpload()); + + await expect(result.current.upload("/x", file, { chunked: chunkedConfig })).rejects.toThrow( + "Server response missing upload_id.", + ); + }); + }); +}); diff --git a/client/src/protoFleet/api/useFileUpload.ts b/client/src/protoFleet/api/useFileUpload.ts new file mode 100644 index 000000000..16ef36c26 --- /dev/null +++ b/client/src/protoFleet/api/useFileUpload.ts @@ -0,0 +1,207 @@ +import { useCallback, useMemo } from "react"; +import { useLogout } from "@/protoFleet/store"; + +export interface ChunkedUploadConfig { + enabled: boolean; + chunkSize: number; + initiateUrl: string; + chunkUrl: (uploadId: string) => string; + completeUrl: (uploadId: string) => string; +} + +export interface FileUploadOptions { + onProgress?: (percent: number) => void; + signal?: AbortSignal; + fieldName?: string; + chunked?: ChunkedUploadConfig; +} + +interface ErrorBody { + error?: string; +} + +export async function extractFetchError(response: Response, fallback: string): Promise { + try { + const data: ErrorBody = await response.json(); + if (data.error) return data.error; + } catch { + /* not JSON */ + } + return fallback; +} + +function extractXhrError(responseText: string, fallback: string): string { + try { + const data: ErrorBody = JSON.parse(responseText); + if (data.error) return data.error; + } catch { + /* not JSON */ + } + return fallback; +} + +function handleAuth401(status: number, logout: () => void): void { + if (status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } +} + +async function uploadChunked( + file: File, + options: FileUploadOptions & { chunked: ChunkedUploadConfig }, + logout: () => void, +): Promise { + const { chunked, onProgress, signal } = options; + const totalChunks = Math.ceil(file.size / chunked.chunkSize); + + const initResponse = await fetch(chunked.initiateUrl, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename: file.name, file_size: file.size }), + signal, + }); + + handleAuth401(initResponse.status, logout); + if (!initResponse.ok) { + throw new Error( + await extractFetchError( + initResponse, + `Failed to initiate upload: ${initResponse.status} ${initResponse.statusText}`, + ), + ); + } + + const initData: { upload_id?: string } = await initResponse.json(); + if (!initData.upload_id) { + throw new Error("Server response missing upload_id."); + } + const uploadId = initData.upload_id; + + for (let i = 0; i < totalChunks; i++) { + if (signal?.aborted) { + throw new Error("Upload was cancelled."); + } + + const start = i * chunked.chunkSize; + const end = Math.min(start + chunked.chunkSize, file.size); + + const chunkResponse = await fetch(chunked.chunkUrl(uploadId), { + method: "PUT", + credentials: "include", + headers: { + "Content-Type": "application/octet-stream", + "Content-Range": `bytes ${start}-${end - 1}/${file.size}`, + }, + body: file.slice(start, end), + signal, + }); + + handleAuth401(chunkResponse.status, logout); + if (!chunkResponse.ok) { + throw new Error( + await extractFetchError( + chunkResponse, + `Chunk upload failed: ${chunkResponse.status} ${chunkResponse.statusText}`, + ), + ); + } + + onProgress?.(Math.round(((i + 1) / totalChunks) * 100)); + } + + const completeResponse = await fetch(chunked.completeUrl(uploadId), { + method: "POST", + credentials: "include", + signal, + }); + + handleAuth401(completeResponse.status, logout); + if (!completeResponse.ok) { + throw new Error( + await extractFetchError( + completeResponse, + `Failed to complete upload: ${completeResponse.status} ${completeResponse.statusText}`, + ), + ); + } + + return completeResponse.json(); +} + +function uploadDirect( + url: string, + file: File, + options: FileUploadOptions | undefined, + logout: () => void, +): Promise { + if (options?.signal?.aborted) { + return Promise.reject(new Error("Upload was cancelled.")); + } + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url); + xhr.withCredentials = true; + + if (options?.onProgress) { + const onProgress = options.onProgress; + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + onProgress(Math.round((event.loaded / event.total) * 100)); + } + }); + } + + if (options?.signal) { + options.signal.addEventListener("abort", () => xhr.abort(), { once: true }); + } + + xhr.addEventListener("load", () => { + if (xhr.status === 401) { + logout(); + reject(new Error("Session expired. Please log in again.")); + return; + } + + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch { + reject(new Error("Invalid response from upload endpoint.")); + } + } else { + const message = extractXhrError(xhr.responseText, `Upload failed: ${xhr.status} ${xhr.statusText}`); + reject(new Error(message)); + } + }); + + xhr.addEventListener("error", () => { + reject(new Error("Network error during upload.")); + }); + + xhr.addEventListener("abort", () => { + reject(new Error("Upload was cancelled.")); + }); + + const formData = new FormData(); + formData.append(options?.fieldName ?? "file", file); + xhr.send(formData); + }); +} + +export const useFileUpload = () => { + const logout = useLogout(); + + const upload = useCallback( + async (url: string, file: File, options?: FileUploadOptions): Promise => { + if (options?.chunked?.enabled) { + return uploadChunked(file, options as FileUploadOptions & { chunked: ChunkedUploadConfig }, logout); + } + return uploadDirect(url, file, options, logout); + }, + [logout], + ); + + return useMemo(() => ({ upload }), [upload]); +}; diff --git a/client/src/protoFleet/api/useFirmwareApi.test.ts b/client/src/protoFleet/api/useFirmwareApi.test.ts new file mode 100644 index 000000000..d3f474a19 --- /dev/null +++ b/client/src/protoFleet/api/useFirmwareApi.test.ts @@ -0,0 +1,461 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { _resetConfigCache, useFirmwareApi, validateFirmwareFile } from "./useFirmwareApi"; + +const mockLogout = vi.fn(); +const mockUpload = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useLogout: () => mockLogout, +})); + +vi.mock("@/protoFleet/api/useFileUpload", async (importOriginal) => ({ + ...(await importOriginal()), + useFileUpload: () => ({ upload: mockUpload }), +})); + +describe("validateFirmwareFile", () => { + const defaultConfig = { allowedExtensions: [".swu", ".tar.gz", ".zip"] }; + const createFile = (name: string, size = 1024): File => new File(["x".repeat(size)], name); + + it("accepts .swu files", () => { + expect(validateFirmwareFile(createFile("firmware.swu"), defaultConfig)).toBeNull(); + }); + + it("accepts .tar.gz files", () => { + expect(validateFirmwareFile(createFile("firmware.tar.gz"), defaultConfig)).toBeNull(); + }); + + it("accepts .zip files", () => { + expect(validateFirmwareFile(createFile("firmware.zip"), defaultConfig)).toBeNull(); + }); + + it("accepts uppercase extensions", () => { + expect(validateFirmwareFile(createFile("firmware.SWU"), defaultConfig)).toBeNull(); + expect(validateFirmwareFile(createFile("firmware.TAR.GZ"), defaultConfig)).toBeNull(); + expect(validateFirmwareFile(createFile("firmware.ZIP"), defaultConfig)).toBeNull(); + }); + + it("rejects unsupported extensions", () => { + expect(validateFirmwareFile(createFile("firmware.bin"), defaultConfig)).toContain("Unsupported file type"); + }); + + it("rejects files with no extension", () => { + expect(validateFirmwareFile(createFile("firmware"), defaultConfig)).toContain("Unsupported file type"); + }); + + it("rejects empty files", () => { + const emptyFile = new File([], "firmware.swu"); + expect(validateFirmwareFile(emptyFile, defaultConfig)).toBe("File is empty."); + }); + + it("rejects files with no filename", () => { + const file = new File(["data"], ""); + expect(validateFirmwareFile(file, defaultConfig)).toBe("No filename provided."); + }); + + it("uses custom extensions from config", () => { + const file = new File(["data"], "firmware.img"); + expect(validateFirmwareFile(file, { allowedExtensions: [".img"] })).toBeNull(); + }); + + it("rejects files exceeding maxFileSizeBytes", () => { + const file = new File(["x".repeat(200)], "firmware.swu"); + expect(validateFirmwareFile(file, { ...defaultConfig, maxFileSizeBytes: 100 })).toContain("File too large"); + }); + + it("accepts files within maxFileSizeBytes", () => { + const file = new File(["x".repeat(50)], "firmware.swu"); + expect(validateFirmwareFile(file, { ...defaultConfig, maxFileSizeBytes: 100 })).toBeNull(); + }); +}); + +describe("useFirmwareApi", () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetConfigCache(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("checkFirmwareFile", () => { + it("sends POST with JSON body and credentials", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ exists: false }), + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + await result.current.checkFirmwareFile("abc123"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/check", + expect.objectContaining({ + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sha256: "abc123" }), + }), + ); + }); + + it("returns exists and firmwareFileId on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ exists: true, firmware_file_id: "file-123" }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + const data = await result.current.checkFirmwareFile("abc123"); + + expect(data).toEqual({ exists: true, firmwareFileId: "file-123" }); + }); + + it("returns exists false when file not found", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ exists: false }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + const data = await result.current.checkFirmwareFile("abc123"); + + expect(data).toEqual({ exists: false, firmwareFileId: undefined }); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.checkFirmwareFile("abc123")).rejects.toThrow("Session expired"); + + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("throws on non-401 HTTP error without calling logout", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: () => Promise.reject(new Error("no body")), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.checkFirmwareFile("abc123")).rejects.toThrow("Firmware check failed: 500"); + + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it("surfaces server error message from JSON body on failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + json: () => Promise.resolve({ error: "sha256 must be a 64-character hex string" }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.checkFirmwareFile("bad")).rejects.toThrow("sha256 must be a 64-character hex string"); + }); + }); + + describe("uploadFirmwareFile", () => { + it("fetches config then delegates to useFileUpload", async () => { + const configFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + max_file_size_bytes: 500 * 1024 * 1024, + chunk_size_bytes: 1 * 1024 * 1024, + }), + }); + vi.stubGlobal("fetch", configFetch); + mockUpload.mockResolvedValue({ firmware_file_id: "fw-abc" }); + + const file = new File(["data"], "firmware.swu"); + const { result } = renderHook(() => useFirmwareApi()); + const id = await result.current.uploadFirmwareFile(file); + + expect(id).toBe("fw-abc"); + expect(mockUpload).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/upload", + file, + expect.objectContaining({ + onProgress: undefined, + signal: undefined, + }), + ); + }); + + it("uses chunked upload when file exceeds chunk size", async () => { + const configFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + chunk_size_bytes: 5, + }), + }); + vi.stubGlobal("fetch", configFetch); + mockUpload.mockResolvedValue({ firmware_file_id: "fw-chunked" }); + + const file = new File(["a".repeat(10)], "firmware.swu"); + const onProgress = vi.fn(); + const { result } = renderHook(() => useFirmwareApi()); + const id = await result.current.uploadFirmwareFile(file, { onProgress }); + + expect(id).toBe("fw-chunked"); + expect(mockUpload).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/upload", + file, + expect.objectContaining({ + onProgress, + chunked: expect.objectContaining({ + enabled: true, + chunkSize: 5, + }), + }), + ); + }); + + it("throws when upload response is missing firmware_file_id", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + max_file_size_bytes: 500 * 1024 * 1024, + chunk_size_bytes: 1 * 1024 * 1024, + }), + }), + ); + mockUpload.mockResolvedValue({}); + + const file = new File(["data"], "firmware.swu"); + const { result } = renderHook(() => useFirmwareApi()); + + await expect(result.current.uploadFirmwareFile(file)).rejects.toThrow( + "Server response missing firmware_file_id.", + ); + }); + + it("passes signal through to useFileUpload", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + allowed_extensions: [".swu"], + max_file_size_bytes: 500 * 1024 * 1024, + chunk_size_bytes: 1 * 1024 * 1024, + }), + }), + ); + mockUpload.mockResolvedValue({ firmware_file_id: "fw-1" }); + + const controller = new AbortController(); + const file = new File(["data"], "firmware.swu"); + const { result } = renderHook(() => useFirmwareApi()); + + await result.current.uploadFirmwareFile(file, { signal: controller.signal }); + + expect(mockUpload).toHaveBeenCalledWith( + expect.any(String), + file, + expect.objectContaining({ signal: controller.signal }), + ); + }); + }); + + describe("listFirmwareFiles", () => { + it("sends GET with credentials and returns file list", async () => { + const mockFiles = [{ id: "f1", filename: "fw.swu", size: 1024, uploaded_at: "2025-01-01T00:00:00Z" }]; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: mockFiles }), + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + const files = await result.current.listFirmwareFiles(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/files", + expect.objectContaining({ + method: "GET", + credentials: "include", + }), + ); + expect(files).toEqual(mockFiles); + }); + + it("returns empty array when no files exist", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ files: [] }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + const files = await result.current.listFirmwareFiles(); + + expect(files).toEqual([]); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.listFirmwareFiles()).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("throws on server error", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: () => Promise.reject(new Error("no body")), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.listFirmwareFiles()).rejects.toThrow("Failed to list firmware files"); + }); + }); + + describe("deleteFirmwareFile", () => { + it("sends DELETE with file ID and credentials", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + await result.current.deleteFirmwareFile("file-123"); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/files/file-123", + expect.objectContaining({ + method: "DELETE", + credentials: "include", + }), + ); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.deleteFirmwareFile("file-123")).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it("throws on 404 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + json: () => Promise.resolve({ error: "firmware file not found" }), + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.deleteFirmwareFile("missing-id")).rejects.toThrow("firmware file not found"); + }); + }); + + describe("deleteAllFirmwareFiles", () => { + it("sends DELETE and returns deleted count", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ deleted_count: 3 }), + }); + vi.stubGlobal("fetch", mockFetch); + + const { result } = renderHook(() => useFirmwareApi()); + const data = await result.current.deleteAllFirmwareFiles(); + + expect(mockFetch).toHaveBeenCalledWith( + "/api-proxy/api/v1/firmware/files", + expect.objectContaining({ + method: "DELETE", + credentials: "include", + }), + ); + expect(data).toEqual({ deleted_count: 3 }); + }); + + it("calls logout on 401 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + const { result } = renderHook(() => useFirmwareApi()); + await expect(result.current.deleteAllFirmwareFiles()).rejects.toThrow("Session expired"); + expect(mockLogout).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/client/src/protoFleet/api/useFirmwareApi.ts b/client/src/protoFleet/api/useFirmwareApi.ts new file mode 100644 index 000000000..25a0c2a08 --- /dev/null +++ b/client/src/protoFleet/api/useFirmwareApi.ts @@ -0,0 +1,268 @@ +import { useCallback, useMemo } from "react"; +import { API_PROXY_BASE } from "@/protoFleet/api/constants"; +import { extractFetchError, useFileUpload } from "@/protoFleet/api/useFileUpload"; +import { useLogout } from "@/protoFleet/store"; + +export { computeSha256 } from "@/protoFleet/utils/crypto"; + +const API_BASE = `${API_PROXY_BASE}/api/v1/firmware`; + +const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024; +const DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024; + +export interface FirmwareConfig { + allowedExtensions: string[]; + maxFileSizeBytes: number; + chunkSizeBytes: number; +} + +let configCache: FirmwareConfig | null = null; +let configPromise: Promise | null = null; + +/** @internal Exported for test cleanup only. */ +export function _resetConfigCache(): void { + configCache = null; + configPromise = null; +} + +async function fetchFirmwareConfig(logout: () => void): Promise { + if (configCache) return configCache; + if (configPromise) return configPromise; + + configPromise = (async () => { + try { + const response = await fetch(`${API_BASE}/config`, { + method: "GET", + credentials: "include", + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + throw new Error(`Failed to load firmware config: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (!Array.isArray(data.allowed_extensions) || data.allowed_extensions.length === 0) { + throw new Error("Server returned invalid firmware config: missing allowed_extensions."); + } + + const config: FirmwareConfig = { + allowedExtensions: data.allowed_extensions, + maxFileSizeBytes: data.max_file_size_bytes ?? DEFAULT_MAX_FILE_SIZE, + chunkSizeBytes: data.chunk_size_bytes ?? DEFAULT_CHUNK_SIZE, + }; + configCache = config; + return config; + } finally { + configPromise = null; + } + })(); + + return configPromise; +} + +export interface FirmwareUploadOptions { + onProgress?: (percent: number) => void; + signal?: AbortSignal; +} + +export function validateFirmwareFile( + file: File, + config: { allowedExtensions: string[]; maxFileSizeBytes?: number }, +): string | null { + if (!file.name) { + return "No filename provided."; + } + const lower = file.name.toLowerCase(); + const valid = config.allowedExtensions.some((ext) => lower.endsWith(ext)); + if (!valid) { + return `Unsupported file type. Allowed: ${config.allowedExtensions.join(", ")}`; + } + if (file.size === 0) { + return "File is empty."; + } + if (config.maxFileSizeBytes && file.size > config.maxFileSizeBytes) { + return `File too large. Maximum size: ${Math.round(config.maxFileSizeBytes / (1024 * 1024))} MB.`; + } + return null; +} + +export interface FirmwareFileInfo { + id: string; + filename: string; + size: number; + uploaded_at: string; +} + +interface CheckFirmwareResponse { + exists: boolean; + firmware_file_id?: string; +} + +export const useFirmwareApi = () => { + const logout = useLogout(); + const { upload } = useFileUpload(); + + const getConfig = useCallback(async (): Promise => { + return fetchFirmwareConfig(logout); + }, [logout]); + + const checkFirmwareFile = useCallback( + async (sha256: string, signal?: AbortSignal): Promise<{ exists: boolean; firmwareFileId?: string }> => { + const response = await fetch(`${API_BASE}/check`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sha256 }), + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Firmware check failed: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + + const data: CheckFirmwareResponse = await response.json(); + return { + exists: data.exists, + firmwareFileId: data.firmware_file_id, + }; + }, + [logout], + ); + + const uploadFirmwareFile = useCallback( + async (file: File, options?: FirmwareUploadOptions): Promise => { + const config = await fetchFirmwareConfig(logout); + + let data: unknown; + const useChunked = file.size > config.chunkSizeBytes; + if (useChunked) { + data = await upload(`${API_BASE}/upload`, file, { + onProgress: options?.onProgress, + signal: options?.signal, + chunked: { + enabled: true, + chunkSize: config.chunkSizeBytes, + initiateUrl: `${API_BASE}/upload/chunked`, + chunkUrl: (id) => `${API_BASE}/upload/chunked/${encodeURIComponent(id)}`, + completeUrl: (id) => `${API_BASE}/upload/chunked/${encodeURIComponent(id)}/complete`, + }, + }); + } else { + data = await upload(`${API_BASE}/upload`, file, { + onProgress: options?.onProgress, + signal: options?.signal, + }); + } + + const result = data as { firmware_file_id?: string }; + if (!result.firmware_file_id) { + throw new Error("Server response missing firmware_file_id."); + } + return result.firmware_file_id; + }, + [logout, upload], + ); + + const listFirmwareFiles = useCallback( + async (signal?: AbortSignal): Promise => { + const response = await fetch(`${API_BASE}/files`, { + method: "GET", + credentials: "include", + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Failed to list firmware files: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + + const data = await response.json(); + return (data.files ?? []) as FirmwareFileInfo[]; + }, + [logout], + ); + + const deleteFirmwareFile = useCallback( + async (fileId: string, signal?: AbortSignal): Promise => { + const response = await fetch(`${API_BASE}/files/${encodeURIComponent(fileId)}`, { + method: "DELETE", + credentials: "include", + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Failed to delete firmware file: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + }, + [logout], + ); + + const deleteAllFirmwareFiles = useCallback( + async (signal?: AbortSignal): Promise<{ deleted_count: number }> => { + const response = await fetch(`${API_BASE}/files`, { + method: "DELETE", + credentials: "include", + signal, + }); + + if (response.status === 401) { + logout(); + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + const message = await extractFetchError( + response, + `Failed to delete all firmware files: ${response.status} ${response.statusText}`, + ); + throw new Error(message); + } + + return (await response.json()) as { deleted_count: number }; + }, + [logout], + ); + + return useMemo( + () => ({ + getConfig, + checkFirmwareFile, + uploadFirmwareFile, + listFirmwareFiles, + deleteFirmwareFile, + deleteAllFirmwareFiles, + }), + [getConfig, checkFirmwareFile, uploadFirmwareFile, listFirmwareFiles, deleteFirmwareFile, deleteAllFirmwareFiles], + ); +}; diff --git a/client/src/protoFleet/api/useFleet.test.ts b/client/src/protoFleet/api/useFleet.test.ts new file mode 100644 index 000000000..3f1e6f247 --- /dev/null +++ b/client/src/protoFleet/api/useFleet.test.ts @@ -0,0 +1,138 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "./clients"; +import useFleet from "./useFleet"; +import { + ListMinerStateSnapshotsResponseSchema, + MinerListFilterSchema, + MinerStateSnapshotSchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./clients", () => ({ + fleetManagementClient: { + listMinerStateSnapshots: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(({ onError }) => onError?.(new Error("auth error"))); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: vi.fn(), + STATUSES: { + error: "error", + }, +})); + +const makeMiner = (deviceIdentifier: string, workerName = "") => + create(MinerStateSnapshotSchema, { + deviceIdentifier, + workerName, + }); + +const makeListResponse = (miners: ReturnType[]) => + create(ListMinerStateSnapshotsResponseSchema, { + miners, + cursor: "", + totalMiners: miners.length, + models: [], + }); + +describe("useFleet", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("queues a refetch requested while a fetch is already in flight", async () => { + let resolveFirst: (value: ReturnType) => void; + + const firstPromise = new Promise>((resolve) => { + resolveFirst = resolve; + }); + + vi.mocked(fleetManagementClient.listMinerStateSnapshots) + .mockReturnValueOnce(firstPromise as Promise) + .mockResolvedValueOnce(makeListResponse([makeMiner("miner-2", "worker-new")])); + + const { result } = renderHook(() => useFleet({ pageSize: 10 })); + + await act(async () => { + result.current.refetch(); + }); + + expect(fleetManagementClient.listMinerStateSnapshots).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirst!(makeListResponse([makeMiner("miner-1", "worker-old")])); + }); + + await waitFor(() => { + expect(fleetManagementClient.listMinerStateSnapshots).toHaveBeenCalledTimes(2); + expect(result.current.minerIds).toEqual(["miner-2"]); + expect(result.current.miners["miner-2"]?.workerName).toBe("worker-new"); + }); + }); + + it("ignores stale responses when a newer request starts", async () => { + let resolveFirst: (value: ReturnType) => void; + + const firstPromise = new Promise>((resolve) => { + resolveFirst = resolve; + }); + + vi.mocked(fleetManagementClient.listMinerStateSnapshots) + .mockReturnValueOnce(firstPromise as Promise) + .mockResolvedValueOnce(makeListResponse([makeMiner("fresh-miner", "fresh-worker")])); + + const initialFilter = create(MinerListFilterSchema, { models: ["initial-model"] }); + const updatedFilter = create(MinerListFilterSchema, { models: ["updated-model"] }); + + const { result, rerender } = renderHook(({ filter }) => useFleet({ pageSize: 10, filter }), { + initialProps: { filter: initialFilter }, + }); + + rerender({ filter: updatedFilter }); + + await waitFor(() => { + expect(fleetManagementClient.listMinerStateSnapshots).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(result.current.minerIds).toEqual(["fresh-miner"]); + expect(result.current.miners["fresh-miner"]?.workerName).toBe("fresh-worker"); + }); + + await act(async () => { + resolveFirst!(makeListResponse([makeMiner("stale-miner", "stale-worker")])); + }); + + await waitFor(() => { + expect(result.current.minerIds).toEqual(["fresh-miner"]); + expect(result.current.miners["fresh-miner"]?.workerName).toBe("fresh-worker"); + }); + }); + + it("updates a visible miner worker name locally before refetch reconciliation", async () => { + vi.mocked(fleetManagementClient.listMinerStateSnapshots).mockResolvedValue( + makeListResponse([makeMiner("miner-1", "worker-old")]), + ); + + const { result } = renderHook(() => useFleet({ pageSize: 10 })); + + await waitFor(() => { + expect(result.current.miners["miner-1"]?.workerName).toBe("worker-old"); + }); + + act(() => { + result.current.updateMinerWorkerName("miner-1", "worker-new"); + }); + + expect(result.current.miners["miner-1"]?.workerName).toBe("worker-new"); + }); +}); diff --git a/client/src/protoFleet/api/useFleet.ts b/client/src/protoFleet/api/useFleet.ts new file mode 100644 index 000000000..d89b1840b --- /dev/null +++ b/client/src/protoFleet/api/useFleet.ts @@ -0,0 +1,429 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create, equals } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { SortConfig, SortConfigSchema } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + MinerListFilter, + MinerListFilterSchema, + MinerStateSnapshot, + MinerStateSnapshotSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +type UseFleetOptions = { + /** + * Enables data fetching and streaming. + * When false, this hook stays idle. + * @default true + */ + enabled?: boolean; + filter?: MinerListFilter; + /** + * Sort configuration for ordering miners. + * When undefined, uses default server-side ordering (discovery order). + */ + sort?: SortConfig; + pageSize?: number; + pairingStatuses?: PairingStatus[]; +}; + +// Constants to prevent re-renders from unstable default values +const DEFAULT_PAIRING_STATUSES: PairingStatus[] = []; +type PendingFetchMode = "refetch" | "refresh"; + +/** + * Hook for managing fleet data with automatic loading, filtering, and pagination. + * + * @param options - Configuration options for the hook + * @param options.filter - Optional filter to apply + * @param options.pageSize - Number of miners to fetch per page (default: 20) + * + * @example + * ```tsx + * const { minerIds, miners, totalMiners, hasMore, isLoading, loadMore, refetch } = useFleet({ + * filter: { status: [ComponentStatus.OK] } + * }); + * + * // With custom page size + * const { minerIds, miners, totalMiners, hasMore, isLoading, loadMore, refetch } = useFleet({ + * pageSize: 50 + * }); + * + * // Load the next page (replaces current data) + * if (hasMore) { + * loadMore(); + * } + * + * // Refetch current filter from scratch + * refetch(); + * ``` + */ +const useFleet = (options: UseFleetOptions = {}) => { + const { + enabled = true, + filter, + sort, + pageSize = 20, + pairingStatuses = DEFAULT_PAIRING_STATUSES, // Use stable reference to prevent re-renders + } = options; + const { handleAuthErrors } = useAuthErrors(); + + // All state is local to this hook instance + const [minerIds, setMinerIds] = useState([]); + const [miners, setMiners] = useState>({}); + const [totalMiners, setTotalMiners] = useState(0); + const [availableModels, setAvailableModels] = useState([]); + + // Pagination state + const [currentPage, setCurrentPage] = useState(0); + // cursorHistory[i] = cursor to pass when fetching page i + // cursorHistory[0] = undefined (first page needs no cursor) + const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]); + + // Internal state for the hook + const [hasMore, setHasMore] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [hasInitialLoadCompleted, setHasInitialLoadCompleted] = useState(false); + const [cursor, setCursor] = useState(); + const pendingFetchModeRef = useRef(null); + const latestRequestIdRef = useRef(0); + + // Fetch initial list using one-time query + const fetchMinerList = useCallback( + async ( + filter: MinerListFilter | undefined, + sort: SortConfig | undefined, + pageCursor?: string, + fetchedPage?: number, + isRefresh: boolean = false, + ) => { + if (!enabled) { + return; + } + + const requestId = ++latestRequestIdRef.current; + setIsLoading(true); + + // Reset initial load flag when fetching page 0 (but not for polling refreshes) + if (!pageCursor && !isRefresh) { + setHasInitialLoadCompleted(false); + } + + try { + // Merge pairing statuses into the filter + const filterWithPairingStatuses = filter ? { ...filter, pairingStatuses } : { pairingStatuses }; + + const response = await fleetManagementClient.listMinerStateSnapshots({ + pageSize, + cursor: pageCursor, + filter: filterWithPairingStatuses, + sort: sort ? [sort] : undefined, + }); + + const { miners, cursor: newCursor, totalMiners: responseTotalMiners, models } = response; + + if (requestId !== latestRequestIdRef.current) { + return; + } + + // Always replace (never append) for page-based pagination + const ids = miners.map((miner) => miner.deviceIdentifier); + const minersMap: Record = {}; + miners.forEach((miner) => { + minersMap[miner.deviceIdentifier] = miner; + }); + + // Only update state if data actually changed — avoids unnecessary + // re-renders of MinerList/deviceItems on every poll when data is unchanged. + setMinerIds((prev) => { + if (prev.length !== ids.length) return ids; + for (let i = 0; i < ids.length; i++) { + if (prev[i] !== ids[i]) return ids; + } + return prev; + }); + setMiners((prev) => { + const prevKeys = Object.keys(prev); + if (prevKeys.length !== ids.length) return minersMap; + for (const miner of miners) { + const prevMiner = prev[miner.deviceIdentifier]; + if (!prevMiner || !equals(MinerStateSnapshotSchema, prevMiner, miner)) return minersMap; + } + return prev; + }); + setTotalMiners(responseTotalMiners); + + // Update available models for filter dropdown + if (models && models.length > 0) { + setAvailableModels(models); + } + + // Store the response cursor for the next page + if (fetchedPage !== undefined) { + setCursorHistory((prev) => { + const next = [...prev]; + next[fetchedPage + 1] = newCursor || undefined; + return next; + }); + } + + // Update internal state (both scopes) + setCursor(newCursor || undefined); + setHasMore(!!newCursor); + } catch (error) { + if (requestId !== latestRequestIdRef.current) { + return; + } + handleAuthErrors({ + error: error, + onError: (err) => { + console.error("Error fetching miner list:", err); + + // Show toast for page 0 fetch errors (not subsequent pages) + if (!pageCursor) { + pushToast({ + status: TOAST_STATUSES.error, + message: "Failed to load miners. Please try again.", + }); + } + }, + }); + } finally { + if (requestId === latestRequestIdRef.current) { + setIsLoading(false); + + // Mark initial load as completed when fetching page 0 (but not for refreshes) + // This ensures UI doesn't get stuck in permanent loading state on error + if (!pageCursor && !isRefresh) { + setHasInitialLoadCompleted(true); + } + + const pendingFetchMode = pendingFetchModeRef.current; + if (pendingFetchMode !== null) { + pendingFetchModeRef.current = null; + + if (pendingFetchMode === "refetch") { + setCurrentPage(0); + setCursorHistory([undefined]); + void fetchMinerListRef.current(filterRef.current, sortRef.current, undefined, 0); + } else { + const currentCursor = cursorHistoryRef.current[currentPageRef.current]; + void fetchMinerListRef.current( + filterRef.current, + sortRef.current, + currentCursor, + currentPageRef.current, + true, + ); + } + } + } + } + }, + [enabled, pairingStatuses, pageSize, handleAuthErrors], + ); + + // Store fetchMinerList in a ref to avoid dependency issues + const fetchMinerListRef = useRef(fetchMinerList); + useEffect(() => { + fetchMinerListRef.current = fetchMinerList; + }, [fetchMinerList]); + + // Store filter in a ref for stable callbacks (refetch, loadMore) + // This prevents callback recreation when filter object reference changes + const filterRef = useRef(filter); + useEffect(() => { + filterRef.current = filter; + }, [filter]); + + // Store sort in a ref for stable callbacks + const sortRef = useRef(sort); + useEffect(() => { + sortRef.current = sort; + }, [sort]); + + // Store cursor in a ref for stable loadMore callback + const cursorRef = useRef(cursor); + useEffect(() => { + cursorRef.current = cursor; + }, [cursor]); + + // Store isLoading in a ref for stable callbacks + const isLoadingRef = useRef(isLoading); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); + + // Store hasMore in a ref for stable loadMore callback + const hasMoreRef = useRef(hasMore); + useEffect(() => { + hasMoreRef.current = hasMore; + }, [hasMore]); + + // Store currentPage in a ref for stable pagination callbacks + const currentPageRef = useRef(currentPage); + useEffect(() => { + currentPageRef.current = currentPage; + }, [currentPage]); + + // Store cursorHistory in a ref for stable pagination callbacks + const cursorHistoryRef = useRef(cursorHistory); + useEffect(() => { + cursorHistoryRef.current = cursorHistory; + }, [cursorHistory]); + + // Stable loadMore callback - uses refs to avoid recreating on state changes + const loadMore = useCallback(() => { + if (!enabled) { + return; + } + + if (hasMoreRef.current && !isLoadingRef.current) { + // Fetch next page - use refs to get current values + fetchMinerListRef.current(filterRef.current, sortRef.current, cursorRef.current); + } + }, [enabled]); + + const goToPage = useCallback( + (targetPage: number) => { + if (!enabled || isLoadingRef.current) return; + const cursor = cursorHistoryRef.current[targetPage]; + setCurrentPage(targetPage); + fetchMinerListRef.current(filterRef.current, sortRef.current, cursor, targetPage); + }, + [enabled], + ); + + const goToNextPage = useCallback(() => { + if (!hasMoreRef.current) return; + goToPage(currentPageRef.current + 1); + }, [goToPage]); + + const goToPrevPage = useCallback(() => { + if (currentPageRef.current === 0) return; + goToPage(currentPageRef.current - 1); + }, [goToPage]); + + // Stable refetch callback - uses refs to avoid recreating on state changes + // This resets to page 0 - use for filter/sort changes + const refetch = useCallback(() => { + if (!enabled) { + return; + } + + if (isLoadingRef.current) { + pendingFetchModeRef.current = "refetch"; + return; + } + + // Reset pagination and start fresh + setCurrentPage(0); + setCursorHistory([undefined]); + fetchMinerListRef.current(filterRef.current, sortRef.current, undefined, 0); + }, [enabled]); + + // Refresh current page without resetting pagination - use for polling + const refreshCurrentPage = useCallback(() => { + if (isLoadingRef.current) { + if (pendingFetchModeRef.current !== "refetch") { + pendingFetchModeRef.current = "refresh"; + } + return; + } + + const currentCursor = cursorHistoryRef.current[currentPageRef.current]; + fetchMinerListRef.current(filterRef.current, sortRef.current, currentCursor, currentPageRef.current, true); + }, []); + + const updateMinerWorkerName = useCallback((deviceIdentifier: string, workerName: string) => { + setMiners((prev) => { + const existingMiner = prev[deviceIdentifier]; + if (!existingMiner || existingMiner.workerName === workerName) { + return prev; + } + + return { + ...prev, + [deviceIdentifier]: create(MinerStateSnapshotSchema, { + ...existingMiner, + workerName, + }), + }; + }); + }, []); + + // Track if this is the initial load and previous filter/sort + const hasLoadedRef = useRef(false); + const wasEnabledRef = useRef(enabled); + const previousFilterRef = useRef(undefined); + const previousSortRef = useRef(undefined); + + // Fetch data when filter or sort changes + useEffect(() => { + if (!enabled) { + wasEnabledRef.current = false; + return; + } + + const wasDisabled = !wasEnabledRef.current; + + // Check if filter actually changed using protobuf deep equality + const filtersEqual = + previousFilterRef.current === filter || // Both undefined or same reference + (previousFilterRef.current !== undefined && + filter !== undefined && + equals(MinerListFilterSchema, previousFilterRef.current, filter)); + + // Check if sort actually changed using protobuf deep equality + const sortsEqual = + previousSortRef.current === sort || // Both undefined or same reference + (previousSortRef.current !== undefined && + sort !== undefined && + equals(SortConfigSchema, previousSortRef.current, sort)); + + const filterChanged = !filtersEqual; + const sortChanged = !sortsEqual; + + if (hasLoadedRef.current && !filterChanged && !sortChanged && !wasDisabled) { + return; // Skip if not first load and neither filter nor sort has changed + } + + // Update refs + previousFilterRef.current = filter; + previousSortRef.current = sort; + hasLoadedRef.current = true; + wasEnabledRef.current = true; + + // Reset cursor and pagination for new filter or sort + if (filterChanged || sortChanged) { + setCursor(undefined); + setCurrentPage(0); + setCursorHistory([undefined]); + } + + // Fetch with filter and sort + void fetchMinerListRef.current(filter, sort, undefined, 0); + }, [enabled, filter, sort]); + + return { + minerIds, + miners, + totalMiners, + hasMore, + isLoading, + hasInitialLoadCompleted, + loadMore, + currentPage, + hasPreviousPage: currentPage > 0, + goToNextPage, + goToPrevPage, + refetch, + refreshCurrentPage, + updateMinerWorkerName, + availableModels, + }; +}; + +export default useFleet; diff --git a/client/src/protoFleet/api/useFleetCounts.ts b/client/src/protoFleet/api/useFleetCounts.ts new file mode 100644 index 000000000..d9eeafda4 --- /dev/null +++ b/client/src/protoFleet/api/useFleetCounts.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { GetMinerStateCountsRequestSchema } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { MinerStateCounts } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseFleetCountsOptions { + pollIntervalMs?: number; +} + +type UseFleetCountsReturn = { + /** Total number of miners */ + totalMiners: number; + /** Counts of miners in different states */ + stateCounts: MinerStateCounts | undefined; + /** Whether the hook is currently loading data */ + isLoading: boolean; + /** Whether at least one successful fetch has completed */ + hasLoaded: boolean; + /** Refetch the counts */ + refetch: () => void; +}; + +/** + * Hook for fetching miner state counts without loading full miner data. + * More efficient than useFleet when only counts are needed (e.g., Dashboard). + * Supports optional polling for periodic refresh. + * + * @example + * ```tsx + * const { totalMiners, stateCounts, isLoading } = useFleetCounts({ pollIntervalMs: 60000 }); + * + * // Display counts + *
Total: {totalMiners}
+ *
Hashing: {stateCounts?.hashingCount ?? 0}
+ *
Offline: {stateCounts?.offlineCount ?? 0}
+ * ``` + */ +const useFleetCounts = (options?: UseFleetCountsOptions): UseFleetCountsReturn => { + const { handleAuthErrors } = useAuthErrors(); + + const [totalMiners, setTotalMiners] = useState(0); + const [stateCounts, setStateCounts] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + + // Monotonic counter to discard stale responses from overlapping requests + const requestIdRef = useRef(0); + // Track whether we've loaded at least once to suppress loading flash on poll refreshes + const hasLoadedRef = useRef(false); + + const fetchCounts = useCallback(async () => { + const thisRequestId = ++requestIdRef.current; + + // Only show loading spinner on first fetch, not subsequent poll refreshes + if (!hasLoadedRef.current) { + setIsLoading(true); + } + + try { + const request = create(GetMinerStateCountsRequestSchema, {}); + const response = await fleetManagementClient.getMinerStateCounts(request); + + // Discard stale response if a newer request was issued + if (thisRequestId !== requestIdRef.current) return; + + setTotalMiners(response.totalMiners); + setStateCounts(response.stateCounts); + } catch (error) { + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error: error, + onError: (err) => { + console.error("Error fetching miner state counts:", err); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + hasLoadedRef.current = true; + setHasLoaded(true); + } + } + }, [handleAuthErrors]); + + // Fetch on mount only — polling handles subsequent refreshes + const hasFetchedRef = useRef(false); + useEffect(() => { + if (hasFetchedRef.current) return; + hasFetchedRef.current = true; + void fetchCounts(); + }, [fetchCounts]); + + // Polling + useEffect(() => { + if (!options?.pollIntervalMs) return; + + const intervalId = setInterval(() => { + void fetchCounts(); + }, options.pollIntervalMs); + + return () => clearInterval(intervalId); + }, [options?.pollIntervalMs, fetchCounts]); + + const refetch = useCallback(() => { + void fetchCounts(); + }, [fetchCounts]); + + return { + totalMiners, + stateCounts, + isLoading, + hasLoaded, + refetch, + }; +}; + +export default useFleetCounts; diff --git a/client/src/protoFleet/api/useForemanImport.ts b/client/src/protoFleet/api/useForemanImport.ts new file mode 100644 index 000000000..d298d27fd --- /dev/null +++ b/client/src/protoFleet/api/useForemanImport.ts @@ -0,0 +1,91 @@ +import { useCallback, useMemo, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { ConnectError } from "@connectrpc/connect"; +import { foremanImportClient } from "@/protoFleet/api/clients"; +import { + CompleteImportRequestSchema, + type CompleteImportResponse, + ForemanCredentialsSchema, + ImportFromForemanRequestSchema, + type ImportFromForemanResponse, +} from "@/protoFleet/api/generated/foremanimport/v1/foremanimport_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +const buildCredentials = (apiKey: string, clientId: string) => create(ForemanCredentialsSchema, { apiKey, clientId }); + +const useForemanImport = () => { + const { handleAuthErrors } = useAuthErrors(); + const [importPending, setImportPending] = useState(false); + + const handleRpcError = useCallback( + (error: unknown, onError?: (m: string) => void) => { + if (error instanceof ConnectError) { + handleAuthErrors({ error, onError: () => onError?.(getErrorMessage(error, "An unexpected error occurred")) }); + } else if (error instanceof Error) { + onError?.(error.message); + } else { + onError?.(getErrorMessage(error)); + } + }, + [handleAuthErrors], + ); + + const importFromForeman = useCallback( + async (args: { + apiKey: string; + clientId: string; + onSuccess: (r: ImportFromForemanResponse) => void; + onError?: (m: string) => void; + }) => { + setImportPending(true); + try { + const response = await foremanImportClient.importFromForeman( + create(ImportFromForemanRequestSchema, { credentials: buildCredentials(args.apiKey, args.clientId) }), + ); + args.onSuccess(response); + } catch (error) { + handleRpcError(error, args.onError); + } finally { + setImportPending(false); + } + }, + [handleRpcError], + ); + + const completeImport = useCallback( + async (args: { + apiKey: string; + clientId: string; + importPools: boolean; + importGroups: boolean; + importRacks: boolean; + pairedDeviceIdentifiers: string[]; + onSuccess: (r: CompleteImportResponse) => void; + onError?: (m: string) => void; + }) => { + try { + const response = await foremanImportClient.completeImport( + create(CompleteImportRequestSchema, { + credentials: buildCredentials(args.apiKey, args.clientId), + importPools: args.importPools, + importGroups: args.importGroups, + importRacks: args.importRacks, + pairedDeviceIdentifiers: args.pairedDeviceIdentifiers, + }), + ); + args.onSuccess(response); + } catch (error) { + handleRpcError(error, args.onError); + } + }, + [handleRpcError], + ); + + return useMemo( + () => ({ importPending, importFromForeman, completeImport }), + [importPending, importFromForeman, completeImport], + ); +}; + +export { useForemanImport }; diff --git a/client/src/protoFleet/api/useLogin.ts b/client/src/protoFleet/api/useLogin.ts new file mode 100644 index 000000000..a646fe161 --- /dev/null +++ b/client/src/protoFleet/api/useLogin.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; + +import { authClient } from "@/protoFleet/api/clients"; +import type { AuthenticateRequest } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { + useSetAuthLoading, + useSetIsAuthenticated, + useSetRole, + useSetSessionExpiry, + useSetUsername, +} from "@/protoFleet/store"; +import { useAuthErrors } from "@/protoFleet/store/hooks/useAuth"; + +interface LoginProps { + onError?: (message: string) => void; + onFinally?: () => void; + onSuccess?: (requiresPasswordChange: boolean) => void; + loginRequest: AuthenticateRequest; + /** + * When true, prevents automatic logout on authentication failure. + * Use this for re-authentication flows (e.g., password change verification) + * where a failed attempt should show an error, not log the user out. + */ + skipLogoutOnError?: boolean; +} + +const useLogin = () => { + const setSessionExpiry = useSetSessionExpiry(); + const setIsAuthenticated = useSetIsAuthenticated(); + const setUsername = useSetUsername(); + const setRole = useSetRole(); + const setAuthLoading = useSetAuthLoading(); + const { handleAuthErrors } = useAuthErrors(); + + const login = useCallback( + async ({ loginRequest, onSuccess, onError, onFinally, skipLogoutOnError }: LoginProps) => { + await authClient + .authenticate(loginRequest) + .then((res) => { + const sessionExpiry = res.sessionExpiry; + const userInfo = res.userInfo; + + if (!userInfo) { + throw new Error("User info missing from authentication response"); + } + + // Session cookie is automatically stored by browser + // We just track the expiry and user info in state + setSessionExpiry(new Date(Number(sessionExpiry) * 1000)); + setIsAuthenticated(true); + setUsername(userInfo.username); + setRole(userInfo.role); + setAuthLoading(false); + onSuccess?.(userInfo.requiresPasswordChange); + }) + .catch((err) => { + if (skipLogoutOnError) { + onError?.(getErrorMessage(err)); + return; + } + + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [setSessionExpiry, setIsAuthenticated, setUsername, setRole, setAuthLoading, handleAuthErrors], + ); + + return login; +}; + +export { useLogin }; diff --git a/client/src/protoFleet/api/useLogout.ts b/client/src/protoFleet/api/useLogout.ts new file mode 100644 index 000000000..6a9526716 --- /dev/null +++ b/client/src/protoFleet/api/useLogout.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +import { authClient } from "@/protoFleet/api/clients"; +import { useFleetStore } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +/** + * Hook for logging out the user. + * Calls the server to invalidate the session, then clears client-side state. + */ +const useLogoutAction = () => { + const navigate = useNavigate(); + + const logout = useCallback(async () => { + try { + // Call server to invalidate session and clear cookie + await authClient.logout({}); + } catch (err) { + // Show error to user since server-side session may still be valid + console.error("Error during server logout:", err); + pushToast({ + message: "Logout may be incomplete. Your session could not be fully invalidated on the server.", + status: TOAST_STATUSES.error, + }); + } finally { + // Always clear client-side auth state + useFleetStore.getState().auth.logout(); + navigate("/auth"); + } + }, [navigate]); + + return logout; +}; + +export { useLogoutAction }; diff --git a/client/src/protoFleet/api/useMinerCommand.ts b/client/src/protoFleet/api/useMinerCommand.ts new file mode 100644 index 000000000..fc9570e58 --- /dev/null +++ b/client/src/protoFleet/api/useMinerCommand.ts @@ -0,0 +1,500 @@ +import { useCallback, useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; +import { ConnectError } from "@connectrpc/connect"; +import { fleetManagementClient, minerCommandClient } from "@/protoFleet/api/clients"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import { + type DeleteMinersRequest, + type DeleteMinersResponse, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + BlinkLEDRequest, + BlinkLEDResponse, + CheckCommandCapabilitiesRequestSchema, + CheckCommandCapabilitiesResponse, + CommandType, + DeviceSelector, + DownloadLogsRequest, + DownloadLogsResponse, + FirmwareUpdateRequest, + FirmwareUpdateResponse, + GetCommandBatchLogBundleRequest, + GetCommandBatchLogBundleResponse, + PerformanceMode, + type PoolSlotConfig, + PoolSlotConfigSchema, + RawPoolInfoSchema, + RebootRequest, + RebootResponse, + SetCoolingModeRequestSchema, + SetCoolingModeResponse, + SetPowerTargetRequestSchema, + SetPowerTargetResponse, + StartMiningRequest, + StartMiningResponse, + StopMiningRequest, + StopMiningResponse, + StreamCommandBatchUpdatesRequest, + StreamCommandBatchUpdatesResponse, + UpdateMinerPasswordRequestSchema, + UpdateMinerPasswordResponse, + UpdateMiningPoolsRequestSchema, + UpdateMiningPoolsResponse, +} from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface BlinkLEDProps { + blinkLEDRequest: BlinkLEDRequest; + onSuccess: (value: BlinkLEDResponse) => void; + onError?: (error: string) => void; +} + +interface StartMiningProps { + startMiningRequest: StartMiningRequest; + onSuccess: (value: StartMiningResponse) => void; + onError?: (error: string) => void; +} + +interface StopMiningProps { + stopMiningRequest: StopMiningRequest; + onSuccess: (value: StopMiningResponse) => void; + onError?: (error: string) => void; +} + +interface DeleteMinersProps { + deleteMinersRequest: DeleteMinersRequest; + onSuccess: (value: DeleteMinersResponse) => void; + onError?: (error: string) => void; +} + +interface RebootProps { + rebootRequest: RebootRequest; + onSuccess: (value: RebootResponse) => void; + onError?: (error: string) => void; +} + +interface StreamCommandBatchUpdatesProps { + streamRequest: StreamCommandBatchUpdatesRequest; + streamAbortController?: AbortController; + onStreamData: (response: StreamCommandBatchUpdatesResponse) => void; + onError?: (error: string) => void; +} + +// Configuration for a single pool slot - either a known pool ID or raw pool info +export type PoolSlotSource = { type: "poolId"; poolId: string } | { type: "rawPool"; url: string; username: string }; + +export interface PoolConfig { + defaultPool: PoolSlotSource; + backup1Pool?: PoolSlotSource; + backup2Pool?: PoolSlotSource; +} + +interface UpdateMiningPoolsProps { + deviceSelector: DeviceSelector; + poolConfig: PoolConfig; + userUsername: string; + userPassword: string; + onSuccess: (value: UpdateMiningPoolsResponse) => void; + onError?: (error: string) => void; +} + +interface SetPowerTargetProps { + deviceSelector: DeviceSelector; + performanceMode: PerformanceMode; + onSuccess: (value: SetPowerTargetResponse) => void; + onError?: (error: string) => void; +} + +interface SetCoolingModeProps { + deviceSelector: DeviceSelector; + coolingMode: CoolingMode; + onSuccess: (value: SetCoolingModeResponse) => void; + onError?: (error: string) => void; +} + +interface CheckCommandCapabilitiesProps { + deviceSelector: DeviceSelector; + commandType: CommandType; + onSuccess: (value: CheckCommandCapabilitiesResponse) => void; + onError?: (error: string) => void; +} + +interface UpdateMinerPasswordProps { + deviceSelector: DeviceSelector; + newPassword: string; + currentPassword: string; + userUsername: string; + userPassword: string; + onSuccess: (value: UpdateMinerPasswordResponse) => void; + onError?: (error: string) => void; +} + +interface DownloadLogsProps { + downloadLogsRequest: DownloadLogsRequest; + onSuccess: (value: DownloadLogsResponse) => void; + onError?: (error: string) => void; +} + +interface FirmwareUpdateProps { + firmwareUpdateRequest: FirmwareUpdateRequest; + onSuccess: (value: FirmwareUpdateResponse) => void; + onError?: (error: string) => void; +} + +interface GetCommandBatchLogBundleProps { + request: GetCommandBatchLogBundleRequest; + onSuccess: (value: GetCommandBatchLogBundleResponse) => void; + onError?: (error: string) => void; +} + +const useMinerCommand = () => { + const { handleAuthErrors } = useAuthErrors(); + + const blinkLED = useCallback( + async ({ blinkLEDRequest, onSuccess, onError }: BlinkLEDProps) => { + await minerCommandClient + .blinkLED(blinkLEDRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const startMining = useCallback( + async ({ startMiningRequest, onSuccess, onError }: StartMiningProps) => { + await minerCommandClient + .startMining(startMiningRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const stopMining = useCallback( + async ({ stopMiningRequest, onSuccess, onError }: StopMiningProps) => { + await minerCommandClient + .stopMining(stopMiningRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const deleteMiners = useCallback( + async ({ deleteMinersRequest, onSuccess, onError }: DeleteMinersProps) => { + await fleetManagementClient + .deleteMiners(deleteMinersRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const reboot = useCallback( + async ({ rebootRequest, onSuccess, onError }: RebootProps) => { + await minerCommandClient + .reboot(rebootRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const streamCommandBatchUpdates = useCallback( + async ({ streamRequest, streamAbortController, onStreamData, onError }: StreamCommandBatchUpdatesProps) => { + try { + for await (const updateResponse of minerCommandClient.streamCommandBatchUpdates(streamRequest, { + signal: streamAbortController?.signal, + })) { + onStreamData(updateResponse); + } + } catch (error) { + if ( + (error instanceof DOMException && error.name === "AbortError") || + (streamAbortController && streamAbortController.signal.aborted) + ) { + // The stream was aborted, do nothing + return; + } else if (error instanceof ConnectError) { + handleAuthErrors({ + error, + onError: () => { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + }, + }); + } else if (typeof error === "string") { + onError?.(error); + } else { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + } + } + }, + [handleAuthErrors], + ); + + const updateMiningPools = useCallback( + async ({ deviceSelector, poolConfig, userUsername, userPassword, onSuccess, onError }: UpdateMiningPoolsProps) => { + const createPoolSlotConfig = (source: PoolSlotSource): PoolSlotConfig => { + if (source.type === "poolId") { + return create(PoolSlotConfigSchema, { + poolSource: { case: "poolId", value: BigInt(source.poolId) }, + }); + } + return create(PoolSlotConfigSchema, { + poolSource: { + case: "rawPool", + value: create(RawPoolInfoSchema, { + url: source.url, + username: source.username, + }), + }, + }); + }; + + const updateMiningPoolsRequest = create(UpdateMiningPoolsRequestSchema, { + deviceSelector, + defaultPool: createPoolSlotConfig(poolConfig.defaultPool), + backup1Pool: poolConfig.backup1Pool ? createPoolSlotConfig(poolConfig.backup1Pool) : undefined, + backup2Pool: poolConfig.backup2Pool ? createPoolSlotConfig(poolConfig.backup2Pool) : undefined, + userUsername, + userPassword, + }); + + await minerCommandClient + .updateMiningPools(updateMiningPoolsRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const setPowerTarget = useCallback( + async ({ deviceSelector, performanceMode, onSuccess, onError }: SetPowerTargetProps) => { + const setPowerTargetRequest = create(SetPowerTargetRequestSchema, { + deviceSelector, + performanceMode, + }); + + await minerCommandClient + .setPowerTarget(setPowerTargetRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const setCoolingMode = useCallback( + async ({ deviceSelector, coolingMode, onSuccess, onError }: SetCoolingModeProps) => { + const setCoolingModeRequest = create(SetCoolingModeRequestSchema, { + deviceSelector, + mode: coolingMode, + }); + + await minerCommandClient + .setCoolingMode(setCoolingModeRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const checkCommandCapabilities = useCallback( + async ({ deviceSelector, commandType, onSuccess, onError }: CheckCommandCapabilitiesProps) => { + const request = create(CheckCommandCapabilitiesRequestSchema, { + deviceSelector, + commandType, + }); + + await minerCommandClient + .checkCommandCapabilities(request) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const updateMinerPassword = useCallback( + async ({ + deviceSelector, + newPassword, + currentPassword, + userUsername, + userPassword, + onSuccess, + onError, + }: UpdateMinerPasswordProps) => { + const request = create(UpdateMinerPasswordRequestSchema, { + deviceSelector, + newPassword, + currentPassword, + userUsername, + userPassword, + }); + + await minerCommandClient + .updateMinerPassword(request) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const downloadLogs = useCallback( + async ({ downloadLogsRequest, onSuccess, onError }: DownloadLogsProps) => { + await minerCommandClient + .downloadLogs(downloadLogsRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const firmwareUpdate = useCallback( + async ({ firmwareUpdateRequest, onSuccess, onError }: FirmwareUpdateProps) => { + await minerCommandClient + .firmwareUpdate(firmwareUpdateRequest) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const getCommandBatchLogBundle = useCallback( + async ({ request, onSuccess, onError }: GetCommandBatchLogBundleProps) => { + await minerCommandClient + .getCommandBatchLogBundle(request) + .then((response) => onSuccess(response)) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + blinkLED, + startMining, + stopMining, + deleteMiners, + reboot, + streamCommandBatchUpdates, + updateMiningPools, + setPowerTarget, + setCoolingMode, + checkCommandCapabilities, + updateMinerPassword, + downloadLogs, + firmwareUpdate, + getCommandBatchLogBundle, + }), + [ + blinkLED, + startMining, + stopMining, + deleteMiners, + reboot, + streamCommandBatchUpdates, + updateMiningPools, + setPowerTarget, + setCoolingMode, + checkCommandCapabilities, + updateMinerPassword, + downloadLogs, + firmwareUpdate, + getCommandBatchLogBundle, + ], + ); +}; + +export { useMinerCommand }; diff --git a/client/src/protoFleet/api/useMinerCoolingMode.ts b/client/src/protoFleet/api/useMinerCoolingMode.ts new file mode 100644 index 000000000..93df68e49 --- /dev/null +++ b/client/src/protoFleet/api/useMinerCoolingMode.ts @@ -0,0 +1,38 @@ +import { useCallback, useMemo } from "react"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { CoolingMode } from "@/protoFleet/api/generated/common/v1/cooling_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useMinerCoolingMode = () => { + const { handleAuthErrors } = useAuthErrors(); + + const fetchCoolingMode = useCallback( + async (deviceIdentifier: string): Promise => { + try { + const response = await fleetManagementClient.getMinerCoolingMode({ + deviceIdentifier, + }); + + return response.coolingMode; + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + console.error("Error fetching miner cooling mode:", err); + }, + }); + return CoolingMode.UNSPECIFIED; + } + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + fetchCoolingMode, + }), + [fetchCoolingMode], + ); +}; + +export default useMinerCoolingMode; diff --git a/client/src/protoFleet/api/useMinerModelGroups.ts b/client/src/protoFleet/api/useMinerModelGroups.ts new file mode 100644 index 000000000..a4ea433a7 --- /dev/null +++ b/client/src/protoFleet/api/useMinerModelGroups.ts @@ -0,0 +1,19 @@ +import { useCallback } from "react"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { + type MinerListFilter, + type MinerModelGroup, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +const useMinerModelGroups = () => { + const getMinerModelGroups = useCallback(async (filter: MinerListFilter | null): Promise => { + const response = await fleetManagementClient.getMinerModelGroups({ + filter: filter ?? undefined, + }); + return response.groups; + }, []); + + return { getMinerModelGroups }; +}; + +export default useMinerModelGroups; diff --git a/client/src/protoFleet/api/useMinerPairing.ts b/client/src/protoFleet/api/useMinerPairing.ts new file mode 100644 index 000000000..be568bb8f --- /dev/null +++ b/client/src/protoFleet/api/useMinerPairing.ts @@ -0,0 +1,96 @@ +import { useCallback, useMemo, useState } from "react"; +import { ConnectError } from "@connectrpc/connect"; +import { pairingClient } from "@/protoFleet/api/clients"; +import { Device, DiscoverRequest, PairRequest } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface DiscoverMinersProps { + discoverRequest: DiscoverRequest; + discoverAbortController?: AbortController; + onStreamData: (devices: Device[]) => void; + onError?: (error: string) => void; +} + +interface PairMinersProps { + pairRequest: PairRequest; + onSuccess: (failedDeviceIds: string[]) => void; + onError?: (error: string) => void; +} + +const useMinerPairing = () => { + const { handleAuthErrors } = useAuthErrors(); + + const [discoverPending, setDiscoverPending] = useState(false); + const [pairingPending, setPairingPending] = useState(false); + + const discover = useCallback( + async ({ discoverRequest, discoverAbortController, onStreamData, onError }: DiscoverMinersProps) => { + setDiscoverPending(true); + try { + for await (const discoveryResponse of pairingClient.discover(discoverRequest, { + signal: discoverAbortController?.signal, + })) { + if (discoveryResponse.error) { + onError?.(discoveryResponse.error); + break; + } + + onStreamData(discoveryResponse.devices); + } + } catch (error) { + if ( + (error instanceof DOMException && error.name === "AbortError") || + (discoverAbortController && discoverAbortController.signal.aborted) + ) { + // The discovery was aborted, do nothing + return; + } else if (error instanceof ConnectError) { + handleAuthErrors({ + error: error, + onError: () => { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + }, + }); + } else if (typeof error === "string") { + onError?.(error); + } else { + onError?.(getErrorMessage(error, "An unexpected error occurred")); + } + } finally { + setDiscoverPending(false); + } + }, + [handleAuthErrors], + ); + + const pair = useCallback( + async ({ pairRequest, onSuccess, onError }: PairMinersProps) => { + setPairingPending(true); + await pairingClient + .pair(pairRequest) + .then((response) => { + onSuccess(response.failedDeviceIds || []); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + setPairingPending(false); + }); + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ discoverPending, discover, pairingPending, pair }), + [discoverPending, discover, pairingPending, pair], + ); +}; + +export { useMinerPairing }; diff --git a/client/src/protoFleet/api/useMinerPoolAssignments.ts b/client/src/protoFleet/api/useMinerPoolAssignments.ts new file mode 100644 index 000000000..daa3d6e67 --- /dev/null +++ b/client/src/protoFleet/api/useMinerPoolAssignments.ts @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState } from "react"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { PoolAssignment } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useMinerPoolAssignments = () => { + const { handleAuthErrors } = useAuthErrors(); + const [pools, setPools] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchPoolAssignments = useCallback( + async (deviceIdentifier: string): Promise => { + setIsLoading(true); + setError(null); + + try { + const response = await fleetManagementClient.getMinerPoolAssignments({ + deviceIdentifier, + }); + + setPools(response.pools); + return response.pools; + } catch (err) { + handleAuthErrors({ + error: err, + onError: () => { + const errorMessage = err instanceof Error ? err.message : String(err); + setError(errorMessage); + console.error("Error fetching miner pool assignments:", err); + }, + }); + return []; + } finally { + setIsLoading(false); + } + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + pools, + isLoading, + error, + fetchPoolAssignments, + }), + [pools, isLoading, error, fetchPoolAssignments], + ); +}; + +export default useMinerPoolAssignments; diff --git a/client/src/protoFleet/api/useNetworkInfo.ts b/client/src/protoFleet/api/useNetworkInfo.ts new file mode 100644 index 000000000..95225a176 --- /dev/null +++ b/client/src/protoFleet/api/useNetworkInfo.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { networkInfoClient } from "@/protoFleet/api/clients"; +import { NetworkInfo, UpdateNetworkNicknameRequest } from "@/protoFleet/api/generated/networkinfo/v1/networkinfo_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UpdateNetworkInfoProps { + networkUpdateRequest: UpdateNetworkNicknameRequest; + onSuccess: () => void; + onError?: (error: string) => void; +} + +const useNetworkInfo = () => { + const { handleAuthErrors } = useAuthErrors(); + + const [data, setData] = useState(); + const [error, setError] = useState(); + const [pending, setPending] = useState(false); + + const fetchData = useCallback(() => { + setPending(true); + + networkInfoClient + .getNetworkInfo({}) + .then((res) => { + setData(res?.networkInfo); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + setError(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + setPending(false); + }); + }, [handleAuthErrors]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + fetchData(); + }, [fetchData]); + + const updateNetworkInfo = useCallback( + async ({ networkUpdateRequest, onSuccess, onError }: UpdateNetworkInfoProps) => { + setPending(true); + await networkInfoClient + .updateNetworkNickname(networkUpdateRequest) + .then(() => { + onSuccess(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + setPending(false); + }); + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ fetchData, pending, error, data, updateNetworkInfo }), + [fetchData, pending, error, data, updateNetworkInfo], + ); +}; + +export { useNetworkInfo }; diff --git a/client/src/protoFleet/api/useOnboardedStatus.ts b/client/src/protoFleet/api/useOnboardedStatus.ts new file mode 100644 index 000000000..1808da451 --- /dev/null +++ b/client/src/protoFleet/api/useOnboardedStatus.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect } from "react"; +import { onboardingClient } from "@/protoFleet/api/clients"; +import type { FleetOnboardingStatus } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { + useAuthErrors, + useDevicePaired, + useIsAuthenticated, + useOnboardingStatusLoaded, + usePoolConfigured, + useResetOnboardingStatus, + useSetOnboardingStatus, +} from "@/protoFleet/store"; + +const useOnboardedStatus = ({ enabled = true }: { enabled?: boolean } = {}) => { + const isAuthenticated = useIsAuthenticated(); + const poolConfigured = usePoolConfigured(); + const devicePaired = useDevicePaired(); + const statusLoaded = useOnboardingStatusLoaded(); + const setStatus = useSetOnboardingStatus(); + const resetStatus = useResetOnboardingStatus(); + const { handleAuthErrors } = useAuthErrors(); + + const fetchStatus = useCallback(async (): Promise => { + try { + const response = await onboardingClient.getFleetOnboardingStatus({}); + setStatus(response.status ?? null); + return response.status ?? null; + } catch (err: any) { + setStatus(null); + handleAuthErrors({ + error: err, + onError: () => { + const errorMessage = getErrorMessage(err); + throw new Error(`Failed to fetch Onboarded Status: ${errorMessage}`); + }, + }); + return null; + } + }, [setStatus, handleAuthErrors]); + + useEffect(() => { + if (!enabled) { + return; + } + + if (!isAuthenticated) { + resetStatus(); + return; + } + + fetchStatus(); + }, [enabled, fetchStatus, isAuthenticated, resetStatus]); + + return { + poolConfigured, + devicePaired, + statusLoaded, + refetch: fetchStatus, + }; +}; + +export { useOnboardedStatus }; diff --git a/client/src/protoFleet/api/usePoolNeededCount.ts b/client/src/protoFleet/api/usePoolNeededCount.ts new file mode 100644 index 000000000..4af0ef002 --- /dev/null +++ b/client/src/protoFleet/api/usePoolNeededCount.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { + DeviceStatus, + ListMinerStateSnapshotsRequestSchema, + MinerListFilterSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +type UsePoolNeededCountReturn = { + /** Total number of miners that need pool configuration */ + poolNeededCount: number; + /** Whether the hook is currently loading data */ + isLoading: boolean; + /** Whether the initial load has completed */ + hasInitialLoadCompleted: boolean; + /** Refetch the count */ + refetch: () => void; +}; + +/** + * Hook for fetching the count of miners that need mining pool configuration. + * + * @example + * ```tsx + * const { poolNeededCount, isLoading } = usePoolNeededCount(); + * + * // Display count + * {poolNeededCount > 0 &&
{poolNeededCount} miners need pools
} + * ``` + */ +const usePoolNeededCount = (): UsePoolNeededCountReturn => { + const { handleAuthErrors } = useAuthErrors(); + + const [poolNeededCount, setPoolNeededCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [hasInitialLoadCompleted, setHasInitialLoadCompleted] = useState(false); + const isLoadingRef = useRef(false); + const fetchCountRef = useRef<(() => Promise) | null>(null); + + // Fetch only the count (lightweight, single page request) + const fetchCount = useCallback(async () => { + setIsLoading(true); + isLoadingRef.current = true; + + try { + // Create filter for NEEDS_MINING_POOL status with PAIRED pairing status + const filter = create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.NEEDS_MINING_POOL], + pairingStatuses: [PairingStatus.PAIRED], + }); + + // Fetch only first page to get total count + const request = create(ListMinerStateSnapshotsRequestSchema, { + pageSize: 1, // Minimal page size since we only need the count + cursor: "", + filter, + }); + + const response = await fleetManagementClient.listMinerStateSnapshots(request); + setPoolNeededCount(response.totalMiners); + } catch (error) { + handleAuthErrors({ + error: error, + onError: (err) => { + console.error("[usePoolNeededCount] Error fetching pool needed count:", err); + }, + }); + } finally { + setIsLoading(false); + isLoadingRef.current = false; + setHasInitialLoadCompleted(true); + } + }, [handleAuthErrors]); + + // Store fetchCount in a ref so refetch callback can access latest version without changing identity + fetchCountRef.current = fetchCount; + + // Track if this is the initial load + const hasLoadedRef = useRef(false); + + // Fetch data on mount + useEffect(() => { + if (hasLoadedRef.current) { + return; + } + hasLoadedRef.current = true; + void fetchCount(); + }, [fetchCount]); + + // Use ref-based approach to keep callback stable while accessing latest fetchCount + const refetch = useCallback(() => { + if (!isLoadingRef.current && fetchCountRef.current) { + void fetchCountRef.current(); + } + }, []); + + return { + poolNeededCount, + isLoading, + hasInitialLoadCompleted, + refetch, + }; +}; + +export default usePoolNeededCount; diff --git a/client/src/protoFleet/api/usePools.ts b/client/src/protoFleet/api/usePools.ts new file mode 100644 index 000000000..68bfbc09b --- /dev/null +++ b/client/src/protoFleet/api/usePools.ts @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Duration } from "@bufbuild/protobuf/wkt"; +import { poolsClient } from "@/protoFleet/api/clients"; +import type { + CreatePoolRequest, + DeletePoolRequest, + ListPoolsResponse, + UpdatePoolRequest, + ValidatePoolRequest, +} from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface CreatePoolProps { + createPoolRequest: CreatePoolRequest; + onSuccess?: (poolId: string) => void; + onError?: (error: string) => void; +} + +interface UpdatePoolProps { + updatePoolRequest: UpdatePoolRequest; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +interface DeletePoolProps { + deletePoolRequest: DeletePoolRequest; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export interface ValidatePoolProps { + poolInfo: Omit; + onSuccess?: () => void; + onError?: (error: string) => void; + onFinally?: () => void; +} + +const usePools = (enabled = true) => { + const { handleAuthErrors } = useAuthErrors(); + + const [pools, setPools] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const fetchPools = useCallback( + async (showLoading = true) => { + try { + if (showLoading) { + setIsLoading(true); + } + const response = await poolsClient.listPools({}); + + setPools(response.pools); + } catch (error) { + handleAuthErrors({ + error: error, + onError: () => { + console.error("Error fetching pools:", error); + throw error; + }, + }); + } finally { + if (showLoading) { + setIsLoading(false); + } + } + }, + [setPools, handleAuthErrors], + ); + + useEffect(() => { + if (!enabled) { + setIsLoading(false); + return; + } + + fetchPools(); + }, [enabled, fetchPools]); + + const createPool = useCallback( + async ({ createPoolRequest, onSuccess, onError }: CreatePoolProps) => { + await poolsClient + .createPool(createPoolRequest) + .then((response) => { + if (!response.pool || !response.pool.poolId) { + onError?.("Pool created but no pool ID returned"); + return; + } + + const pool = response.pool; + const poolId = pool.poolId; + + setPools((prevPools) => [...prevPools, pool]); + + onSuccess?.(poolId.toString()); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const updatePool = useCallback( + async ({ updatePoolRequest, onSuccess, onError }: UpdatePoolProps) => { + await poolsClient + .updatePool(updatePoolRequest) + .then(() => { + fetchPools(false); // Don't show loading spinner on refetch + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors, fetchPools], + ); + + const deletePool = useCallback( + async ({ deletePoolRequest, onSuccess, onError }: DeletePoolProps) => { + await poolsClient + .deletePool(deletePoolRequest) + .then(() => { + setPools((prevPools) => prevPools.filter((pool) => pool.poolId !== deletePoolRequest.poolId)); + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }); + }, + [handleAuthErrors], + ); + + const [validatePoolPending, setValidatePoolPending] = useState(false); + const validatePool = useCallback( + async ({ poolInfo, onSuccess, onError, onFinally }: ValidatePoolProps) => { + setValidatePoolPending(true); + + // Create request object, only include password if it's not empty + const request: Omit = { + url: poolInfo.url, + username: poolInfo.username, + ...(poolInfo.password && poolInfo.password.trim() && { password: poolInfo.password }), + ...(poolInfo.timeout && { + timeout: poolInfo.timeout as Duration, + }), + }; + + await poolsClient + .validatePool(request) + .then(() => { + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + setValidatePoolPending(false); + }); + }, + [handleAuthErrors], + ); + + // Sort pools by name (case-insensitive) for consistent display + const sortedPools = useMemo( + () => [...pools].sort((a, b) => a.poolName.localeCompare(b.poolName, undefined, { sensitivity: "base" })), + [pools], + ); + + const miningPools = useMemo( + () => + sortedPools.map((pool) => ({ + poolId: pool.poolId.toString(), + name: pool.poolName, + poolUrl: pool.url, + username: pool.username, + })), + [sortedPools], + ); + + return useMemo( + () => ({ + pools: sortedPools, + miningPools, + createPool, + updatePool, + deletePool, + validatePool, + validatePoolPending, + isLoading, + }), + [sortedPools, miningPools, createPool, updatePool, deletePool, validatePool, validatePoolPending, isLoading], + ); +}; + +export default usePools; diff --git a/client/src/protoFleet/api/useRenameMiners.ts b/client/src/protoFleet/api/useRenameMiners.ts new file mode 100644 index 000000000..347cb0f2e --- /dev/null +++ b/client/src/protoFleet/api/useRenameMiners.ts @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { type SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type DeviceSelector, + DeviceSelectorSchema, + type MinerNameConfig, + MinerNameConfigSchema, + NamePropertySchema, + RenameMinersRequestSchema, + type RenameMinersResponse, + StringPropertySchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useRenameMiners = () => { + const { handleAuthErrors } = useAuthErrors(); + + const renameMiners = useCallback( + async ( + deviceSelector: DeviceSelector, + nameConfig: MinerNameConfig, + sort?: SortConfig, + ): Promise => { + try { + return await fleetManagementClient.renameMiners( + create(RenameMinersRequestSchema, { + deviceSelector, + nameConfig, + sort: sort ? [sort] : [], + }), + ); + } catch (err) { + handleAuthErrors({ + error: err, + }); + throw err; + } + }, + [handleAuthErrors], + ); + + const renameSingleMiner = useCallback( + async (deviceIdentifier: string, name: string) => { + await renameMiners( + create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: [deviceIdentifier] }), + }, + }), + create(MinerNameConfigSchema, { + properties: [ + create(NamePropertySchema, { + kind: { + case: "stringValue", + value: create(StringPropertySchema, { value: name }), + }, + }), + ], + separator: "", + }), + ); + }, + [renameMiners], + ); + + return useMemo(() => ({ renameMiners, renameSingleMiner }), [renameMiners, renameSingleMiner]); +}; + +export default useRenameMiners; diff --git a/client/src/protoFleet/api/useScheduleApi.test.ts b/client/src/protoFleet/api/useScheduleApi.test.ts new file mode 100644 index 000000000..faeb05e13 --- /dev/null +++ b/client/src/protoFleet/api/useScheduleApi.test.ts @@ -0,0 +1,487 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; + +import { SCHEDULES_CHANGED_EVENT } from "./scheduleEvents"; +import useScheduleApi from "./useScheduleApi"; +import { scheduleClient } from "@/protoFleet/api/clients"; +import { + DayOfWeek, + DeleteScheduleResponseSchema, + ListSchedulesResponseSchema, + PauseScheduleResponseSchema, + ScheduleAction as ProtoScheduleAction, + ScheduleStatus as ProtoScheduleStatus, + ScheduleType as ProtoScheduleType, + RecurrenceFrequency, + ReorderSchedulesResponseSchema, + ResumeScheduleResponseSchema, + ScheduleRecurrenceSchema, + ScheduleSchema, + ScheduleTargetSchema, + ScheduleTargetType, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; + +vi.mock("@/protoFleet/api/clients", () => ({ + scheduleClient: { + listSchedules: vi.fn(), + createSchedule: vi.fn(), + updateSchedule: vi.fn(), + deleteSchedule: vi.fn(), + pauseSchedule: vi.fn(), + resumeSchedule: vi.fn(), + reorderSchedules: vi.fn(), + }, +})); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: () => ({ + handleAuthErrors: ({ onError, error }: { onError?: (error: unknown) => void; error: unknown }) => { + onError?.(error); + }, + }), +})); + +const mockListSchedules = vi.mocked(scheduleClient.listSchedules); +const mockPauseSchedule = vi.mocked(scheduleClient.pauseSchedule); +const mockResumeSchedule = vi.mocked(scheduleClient.resumeSchedule); +const mockDeleteSchedule = vi.mocked(scheduleClient.deleteSchedule); +const mockReorderSchedules = vi.mocked(scheduleClient.reorderSchedules); +const dayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "short" }); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +const createTimestamp = (value: string) => { + const date = new Date(value); + + return create(TimestampSchema, { + seconds: BigInt(Math.floor(date.getTime() / 1000)), + nanos: (date.getTime() % 1000) * 1_000_000, + }); +}; + +const createDeferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +}; + +const formatExpectedNextRunSummary = (value: string) => { + const nextRun = new Date(value); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const nextRunDay = new Date(nextRun.getFullYear(), nextRun.getMonth(), nextRun.getDate()); + const dayDifference = Math.round((nextRunDay.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + + if (dayDifference === 0) { + return `Runs today at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference === 1) { + return `Runs tomorrow at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference > 1 && dayDifference < 7) { + return `Runs ${dayFormatter.format(nextRun)} at ${timeFormatter.format(nextRun)}`; + } + + return `Runs on ${dateTimeFormatter.format(nextRun)}`; +}; + +const createScheduleMessage = ({ + id, + priority, + name, + action, + status, + createdBy, + createdByUsername, + startDate, + startTime, + timezone = "America/Toronto", + nextRunAt, + targets = [], + recurrence, +}: { + id: bigint; + priority: number; + name: string; + action: ProtoScheduleAction; + status: ProtoScheduleStatus; + createdBy: bigint; + createdByUsername?: string; + startDate: string; + startTime: string; + timezone?: string; + nextRunAt?: string; + targets?: Array<{ targetType: ScheduleTargetType; targetId: string }>; + recurrence?: Partial<{ + frequency: RecurrenceFrequency; + interval: number; + daysOfWeek: DayOfWeek[]; + dayOfMonth?: number; + }>; +}) => + create(ScheduleSchema, { + id, + priority, + name, + action, + status, + createdBy, + createdByUsername, + scheduleType: ProtoScheduleType.RECURRING, + recurrence: create(ScheduleRecurrenceSchema, { + frequency: RecurrenceFrequency.WEEKLY, + interval: 1, + daysOfWeek: [DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY], + ...recurrence, + }), + startDate, + startTime, + endTime: action === ProtoScheduleAction.SET_POWER_TARGET ? "06:00" : "", + timezone, + nextRunAt: nextRunAt ? createTimestamp(nextRunAt) : undefined, + targets: targets.map((target) => create(ScheduleTargetSchema, target)), + }); + +describe("useScheduleApi", () => { + let dispatchEventSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-30T09:00:00-04:00")); + dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); + }); + + afterEach(() => { + vi.useRealTimers(); + dispatchEventSpy.mockRestore(); + }); + + it("lists schedules from the schedule service and maps them into list rows", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 2n, + priority: 2, + name: "Night sleep", + action: ProtoScheduleAction.SLEEP, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 2n, + createdByUsername: "Rongxin Liu", + startDate: "2026-03-30", + startTime: "22:00", + timezone: "America/Chicago", + nextRunAt: "2026-04-01T02:00:00.000Z", + }), + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy: 1n, + createdByUsername: "Negar Naghshbandi", + startDate: "2026-03-30", + startTime: "07:00", + nextRunAt: "2026-03-31T11:00:00.000Z", + targets: [{ targetType: ScheduleTargetType.MINER, targetId: "miner-1" }], + }), + ], + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules.map((schedule) => schedule.id)).toEqual(["1", "2"]); + expect(result.current.schedules[0]).toMatchObject({ + name: "Morning reboot", + targetSummary: "Applies to 1 miner", + action: "reboot", + status: "paused", + createdBy: "Negar Naghshbandi", + }); + expect(result.current.schedules[1]).toMatchObject({ + name: "Night sleep", + targetSummary: "Applies to all miners", + scheduleSummary: `Weekdays · ${timeFormatter.format(new Date("2026-04-01T03:00:00.000Z"))}`, + action: "sleep", + status: "active", + createdBy: "Rongxin Liu", + }); + expect(result.current.schedules[1].nextRunSummary).toBe(formatExpectedNextRunSummary("2026-04-01T02:00:00.000Z")); + }); + + it("prefers the server-provided creator username when schedules include it", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + createdByUsername: "admin@example.com", + startDate: "2026-03-30", + startTime: "07:00", + }), + ], + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]?.createdBy).toBe("admin@example.com"); + }); + + it("keeps the loading flag idle during background refreshes", async () => { + const deferred = createDeferred>>(); + mockListSchedules.mockReturnValue(deferred.promise); + + const { result } = renderHook(() => useScheduleApi()); + + let refreshPromise: Promise | undefined; + + await act(async () => { + refreshPromise = result.current.refreshSchedules({ background: true }); + }); + + expect(result.current.isLoading).toBe(false); + + deferred.resolve( + create(ListSchedulesResponseSchema, { + schedules: [], + }), + ); + + await act(async () => { + await refreshPromise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it("reuses the same in-flight refresh across background and foreground callers", async () => { + const deferred = createDeferred>>(); + mockListSchedules.mockReturnValue(deferred.promise); + + const { result } = renderHook(() => useScheduleApi()); + + let backgroundRefreshPromise: Promise | undefined; + let foregroundRefreshPromise: Promise | undefined; + + await act(async () => { + backgroundRefreshPromise = result.current.refreshSchedules({ background: true }); + foregroundRefreshPromise = result.current.refreshSchedules(); + }); + + expect(mockListSchedules).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(true); + + deferred.resolve( + create(ListSchedulesResponseSchema, { + schedules: [], + }), + ); + + await act(async () => { + await Promise.all([backgroundRefreshPromise, foregroundRefreshPromise]); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it("pauses and resumes schedules via the schedule service", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + ], + }), + ); + mockPauseSchedule.mockResolvedValue( + create(PauseScheduleResponseSchema, { + schedule: createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + }), + ); + mockResumeSchedule.mockResolvedValue( + create(ResumeScheduleResponseSchema, { + schedule: createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.refreshSchedules(); + await result.current.pauseSchedule("1"); + await result.current.resumeSchedule("1"); + }); + + expect(mockPauseSchedule).toHaveBeenCalledWith(expect.objectContaining({ scheduleId: 1n })); + expect(mockResumeSchedule).toHaveBeenCalledWith(expect.objectContaining({ scheduleId: 1n })); + expect(result.current.schedules[0]?.status).toBe("active"); + expect(dispatchEventSpy.mock.calls.map(([event]: [Event]) => event.type)).toContain(SCHEDULES_CHANGED_EVENT); + }); + + it("reorders schedules through the service and removes deleted schedules locally", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Morning reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + }), + createScheduleMessage({ + id: 2n, + priority: 2, + name: "Night curtailment", + action: ProtoScheduleAction.SET_POWER_TARGET, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 2n, + startDate: "2026-03-30", + startTime: "22:00", + }), + ], + }), + ); + mockReorderSchedules.mockResolvedValue(create(ReorderSchedulesResponseSchema, {})); + mockDeleteSchedule.mockResolvedValue(create(DeleteScheduleResponseSchema, {})); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + await result.current.reorderSchedules(["2", "1"]); + await result.current.deleteSchedule("1"); + }); + + expect(mockReorderSchedules).toHaveBeenCalledWith(expect.objectContaining({ scheduleIds: [2n, 1n] })); + expect(mockDeleteSchedule).toHaveBeenCalledWith(expect.objectContaining({ scheduleId: 1n })); + expect(result.current.schedules).toEqual([ + expect.objectContaining({ + id: "2", + priority: 1, + }), + ]); + expect(dispatchEventSpy.mock.calls.map(([event]: [Event]) => event.type)).toContain(SCHEDULES_CHANGED_EVENT); + }); + + it("includes weekly and monthly recurrence patterns in schedule summaries", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Midweek reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 1n, + startDate: "2026-03-30", + startTime: "07:00", + nextRunAt: "2026-04-01T11:00:00.000Z", + recurrence: { + frequency: RecurrenceFrequency.WEEKLY, + interval: 1, + daysOfWeek: [DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY], + }, + }), + createScheduleMessage({ + id: 2n, + priority: 2, + name: "Monthly reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.ACTIVE, + createdBy: 2n, + startDate: "2026-03-30", + startTime: "02:00", + nextRunAt: "2026-04-01T06:00:00.000Z", + recurrence: { + frequency: RecurrenceFrequency.MONTHLY, + interval: 1, + dayOfMonth: 1, + daysOfWeek: [], + }, + }), + ], + }), + ); + + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]).toMatchObject({ + name: "Midweek reboot", + scheduleSummary: `Mon, Wed · ${timeFormatter.format(new Date("2026-04-01T11:00:00.000Z"))}`, + }); + expect(result.current.schedules[1]).toMatchObject({ + name: "Monthly reboot", + scheduleSummary: `1st day of month · ${timeFormatter.format(new Date("2026-04-01T06:00:00.000Z"))}`, + }); + }); +}); diff --git a/client/src/protoFleet/api/useScheduleApi.timezone.test.ts b/client/src/protoFleet/api/useScheduleApi.timezone.test.ts new file mode 100644 index 000000000..ce0f00d56 --- /dev/null +++ b/client/src/protoFleet/api/useScheduleApi.timezone.test.ts @@ -0,0 +1,165 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import process from "node:process"; + +import { + DayOfWeek, + ListSchedulesResponseSchema, + ScheduleAction as ProtoScheduleAction, + ScheduleStatus as ProtoScheduleStatus, + ScheduleType as ProtoScheduleType, + RecurrenceFrequency, + ScheduleRecurrenceSchema, + ScheduleSchema, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; + +const { mockListSchedules } = vi.hoisted(() => ({ + mockListSchedules: vi.fn(), +})); + +vi.mock("@/protoFleet/api/clients", () => ({ + scheduleClient: { + listSchedules: mockListSchedules, + createSchedule: vi.fn(), + updateSchedule: vi.fn(), + deleteSchedule: vi.fn(), + pauseSchedule: vi.fn(), + resumeSchedule: vi.fn(), + reorderSchedules: vi.fn(), + }, +})); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: () => ({ + handleAuthErrors: ({ onError, error }: { onError?: (error: unknown) => void; error: unknown }) => { + onError?.(error); + }, + }), +})); + +const createScheduleMessage = ({ + id, + priority, + name, + createdBy, + startDate, + startTime, + timezone, +}: { + id: bigint; + priority: number; + name: string; + createdBy: bigint; + startDate: string; + startTime: string; + timezone: string; +}) => + create(ScheduleSchema, { + id, + priority, + name, + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy, + scheduleType: ProtoScheduleType.RECURRING, + recurrence: create(ScheduleRecurrenceSchema, { + frequency: RecurrenceFrequency.WEEKLY, + interval: 1, + daysOfWeek: [DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY], + }), + startDate, + startTime, + timezone, + }); + +describe("useScheduleApi DST schedule summaries", () => { + const originalTimeZone = process.env.TZ; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + process.env.TZ = "UTC"; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-10T09:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + + if (originalTimeZone === undefined) { + delete process.env.TZ; + return; + } + + process.env.TZ = originalTimeZone; + }); + + it("uses the current schedule date for recurring summaries when nextRunAt is missing", async () => { + const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }); + + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + createScheduleMessage({ + id: 1n, + priority: 1, + name: "Weekday reboot", + createdBy: 1n, + startDate: "2026-01-15", + startTime: "07:00", + timezone: "America/New_York", + }), + ], + }), + ); + + const { default: useScheduleApi } = await import("./useScheduleApi"); + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]).toMatchObject({ + name: "Weekday reboot", + scheduleSummary: `Weekdays · ${timeFormatter.format(new Date("2026-07-10T11:00:00.000Z"))}`, + }); + }); + + it("does not shift nonexistent DST-gap wall-clock times to a different local hour", async () => { + mockListSchedules.mockResolvedValue( + create(ListSchedulesResponseSchema, { + schedules: [ + create(ScheduleSchema, { + id: 2n, + priority: 1, + name: "Spring-forward reboot", + action: ProtoScheduleAction.REBOOT, + status: ProtoScheduleStatus.PAUSED, + createdBy: 1n, + scheduleType: ProtoScheduleType.ONE_TIME, + startDate: "2026-03-08", + startTime: "02:30", + timezone: "America/New_York", + }), + ], + }), + ); + + const { default: useScheduleApi } = await import("./useScheduleApi"); + const { result } = renderHook(() => useScheduleApi()); + + await act(async () => { + await result.current.listSchedules(); + }); + + expect(result.current.schedules[0]).toMatchObject({ + name: "Spring-forward reboot", + scheduleSummary: "2026-03-08 at 02:30", + }); + }); +}); diff --git a/client/src/protoFleet/api/useScheduleApi.ts b/client/src/protoFleet/api/useScheduleApi.ts new file mode 100644 index 000000000..355d6c3a5 --- /dev/null +++ b/client/src/protoFleet/api/useScheduleApi.ts @@ -0,0 +1,597 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { scheduleClient } from "@/protoFleet/api/clients"; +import { + type CreateScheduleRequest, + DayOfWeek, + DeleteScheduleRequestSchema, + ListSchedulesRequestSchema, + PauseScheduleRequestSchema, + ScheduleAction as ProtoScheduleAction, + ScheduleStatus as ProtoScheduleStatus, + ScheduleType as ProtoScheduleType, + RecurrenceFrequency, + type ReorderSchedulesRequest, + ReorderSchedulesRequestSchema, + ResumeScheduleRequestSchema, + type Schedule, + ScheduleTargetType, + type UpdateScheduleRequest, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import { emitSchedulesChanged } from "@/protoFleet/api/scheduleEvents"; +import { + addDaysToDateValue, + buildDateInTimeZone, + formatTimeZoneDateParts, + getTimeZoneDateTimeParts, +} from "@/protoFleet/features/settings/utils/scheduleDateUtils"; +import { useAuthErrors } from "@/protoFleet/store"; + +export type ScheduleAction = "setPowerTarget" | "reboot" | "sleep"; +export type ScheduleStatus = "running" | "active" | "paused" | "completed"; + +export interface ScheduleListItem { + id: string; + priority: number; + name: string; + targetSummary: string; + scheduleSummary: string; + nextRunSummary: string | null; + action: ScheduleAction; + status: ScheduleStatus; + createdBy: string; + rawSchedule: Schedule; +} + +interface RefreshSchedulesOptions { + background?: boolean; +} + +const dayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "short" }); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +const normalizeSchedules = (schedules: ScheduleListItem[]): ScheduleListItem[] => + [...schedules] + .sort((left, right) => left.priority - right.priority) + .map((schedule, index) => ({ + ...schedule, + priority: index + 1, + })); + +const resequenceSchedules = (schedules: ScheduleListItem[]): ScheduleListItem[] => + schedules.map((schedule, index) => ({ + ...schedule, + priority: index + 1, + })); + +const ensureError = (error: unknown, fallbackMessage: string) => + error instanceof Error ? error : new Error(typeof error === "string" ? error : fallbackMessage); + +const toDate = (seconds: bigint, nanos = 0) => new Date(Number(seconds) * 1000 + Math.floor(nanos / 1_000_000)); + +const formatTimeValue = (value: string, timeZone: string, dateValue: string) => { + const parsed = buildDateInTimeZone(dateValue, value, timeZone); + return parsed ? timeFormatter.format(parsed) : value; +}; + +const formatDateTimeValue = (dateValue: string, timeValue: string, timeZone: string) => { + const parsed = buildDateInTimeZone(dateValue, timeValue, timeZone); + return parsed ? dateTimeFormatter.format(parsed) : `${dateValue} at ${timeValue}`; +}; + +const formatOrdinal = (value: number) => { + const suffix = + value % 10 === 1 && value % 100 !== 11 + ? "st" + : value % 10 === 2 && value % 100 !== 12 + ? "nd" + : value % 10 === 3 && value % 100 !== 13 + ? "rd" + : "th"; + return `${value}${suffix}`; +}; + +const weekdayNames: Record = { + [DayOfWeek.UNSPECIFIED]: "", + [DayOfWeek.SUNDAY]: "Sun", + [DayOfWeek.MONDAY]: "Mon", + [DayOfWeek.TUESDAY]: "Tue", + [DayOfWeek.WEDNESDAY]: "Wed", + [DayOfWeek.THURSDAY]: "Thu", + [DayOfWeek.FRIDAY]: "Fri", + [DayOfWeek.SATURDAY]: "Sat", +}; + +const mapStatus = (status: ProtoScheduleStatus): ScheduleStatus => { + switch (status) { + case ProtoScheduleStatus.RUNNING: + return "running"; + case ProtoScheduleStatus.PAUSED: + return "paused"; + case ProtoScheduleStatus.COMPLETED: + return "completed"; + case ProtoScheduleStatus.ACTIVE: + case ProtoScheduleStatus.UNSPECIFIED: + default: + return "active"; + } +}; + +const mapAction = (schedule: Schedule): ScheduleAction => { + switch (schedule.action) { + case ProtoScheduleAction.REBOOT: + return "reboot"; + case ProtoScheduleAction.SLEEP: + return "sleep"; + case ProtoScheduleAction.SET_POWER_TARGET: + case ProtoScheduleAction.UNSPECIFIED: + default: + return "setPowerTarget"; + } +}; + +const summarizeTargets = (schedule: Schedule) => { + if (schedule.targets.length === 0) { + return "Applies to all miners"; + } + + const rackCount = schedule.targets.filter((target) => target.targetType === ScheduleTargetType.RACK).length; + const groupCount = schedule.targets.filter((target) => target.targetType === ScheduleTargetType.GROUP).length; + const minerCount = schedule.targets.filter((target) => target.targetType === ScheduleTargetType.MINER).length; + const parts = [ + rackCount > 0 ? `${rackCount} ${rackCount === 1 ? "rack" : "racks"}` : null, + groupCount > 0 ? `${groupCount} ${groupCount === 1 ? "group" : "groups"}` : null, + minerCount > 0 ? `${minerCount} ${minerCount === 1 ? "miner" : "miners"}` : null, + ].filter(Boolean); + + if (parts.length === 0) { + return "Applies to all miners"; + } + + if (parts.length === 1) { + return `Applies to ${parts[0]}`; + } + + return `Applies to ${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`; +}; + +const summarizeWeeklyRecurrence = (daysOfWeek: DayOfWeek[]) => { + const uniqueDays = Array.from(new Set(daysOfWeek)).sort((left, right) => left - right); + + if (uniqueDays.length === 7) { + return "Every day"; + } + + const weekdaySet = new Set([ + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ]); + const weekendSet = new Set([DayOfWeek.SATURDAY, DayOfWeek.SUNDAY]); + + if (uniqueDays.length === weekdaySet.size && uniqueDays.every((day) => weekdaySet.has(day))) { + return "Weekdays"; + } + + if (uniqueDays.length === weekendSet.size && uniqueDays.every((day) => weekendSet.has(day))) { + return "Weekends"; + } + + return uniqueDays + .map((day) => weekdayNames[day]) + .filter(Boolean) + .join(", "); +}; + +const summarizeRecurringPattern = (schedule: Schedule) => { + const recurrence = schedule.recurrence; + + if (!recurrence) { + return "Recurring"; + } + + switch (recurrence.frequency) { + case RecurrenceFrequency.DAILY: + return "Every day"; + case RecurrenceFrequency.WEEKLY: + return summarizeWeeklyRecurrence(recurrence.daysOfWeek); + case RecurrenceFrequency.MONTHLY: + return recurrence.dayOfMonth ? `${formatOrdinal(recurrence.dayOfMonth)} day of month` : "Every month"; + case RecurrenceFrequency.UNSPECIFIED: + default: + return "Recurring"; + } +}; + +const getReferenceDateValue = (schedule: Schedule) => { + if (!schedule.nextRunAt) { + if (schedule.scheduleType === ProtoScheduleType.RECURRING) { + const currentDateParts = getTimeZoneDateTimeParts(new Date(), schedule.timezone); + + if (currentDateParts) { + return formatTimeZoneDateParts(currentDateParts); + } + } + + return schedule.startDate; + } + + const nextRunParts = getTimeZoneDateTimeParts( + toDate(schedule.nextRunAt.seconds, schedule.nextRunAt.nanos), + schedule.timezone, + ); + + return nextRunParts ? formatTimeZoneDateParts(nextRunParts) : schedule.startDate; +}; + +const summarizeTimeWindow = (schedule: Schedule) => { + const referenceDateValue = getReferenceDateValue(schedule); + const startTime = formatTimeValue(schedule.startTime, schedule.timezone, referenceDateValue); + + if (schedule.action !== ProtoScheduleAction.SET_POWER_TARGET || !schedule.endTime) { + return startTime; + } + + const endDateValue = + schedule.endTime < schedule.startTime ? addDaysToDateValue(referenceDateValue, 1) : referenceDateValue; + + return `${startTime} – ${formatTimeValue(schedule.endTime, schedule.timezone, endDateValue)}`; +}; + +const summarizeSchedule = (schedule: Schedule) => { + if (schedule.scheduleType === ProtoScheduleType.ONE_TIME) { + if (schedule.nextRunAt) { + return dateTimeFormatter.format(toDate(schedule.nextRunAt.seconds, schedule.nextRunAt.nanos)); + } + + return formatDateTimeValue(schedule.startDate, schedule.startTime, schedule.timezone); + } + + return `${summarizeRecurringPattern(schedule)} · ${summarizeTimeWindow(schedule)}`; +}; + +const summarizeNextRun = (schedule: Schedule) => { + if (!schedule.nextRunAt) { + return null; + } + + const nextRun = toDate(schedule.nextRunAt.seconds, schedule.nextRunAt.nanos); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const nextRunDay = new Date(nextRun.getFullYear(), nextRun.getMonth(), nextRun.getDate()); + const dayDifference = Math.round((nextRunDay.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + + if (dayDifference === 0) { + return `Runs today at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference === 1) { + return `Runs tomorrow at ${timeFormatter.format(nextRun)}`; + } + + if (dayDifference > 1 && dayDifference < 7) { + return `Runs ${dayFormatter.format(nextRun)} at ${timeFormatter.format(nextRun)}`; + } + + return `Runs on ${dateTimeFormatter.format(nextRun)}`; +}; + +const summarizeCreatedBy = (schedule: Schedule) => schedule.createdByUsername || schedule.createdBy.toString(); + +const mapSchedule = (schedule: Schedule): ScheduleListItem => ({ + id: schedule.id.toString(), + priority: schedule.priority, + name: schedule.name, + targetSummary: summarizeTargets(schedule), + scheduleSummary: summarizeSchedule(schedule), + nextRunSummary: summarizeNextRun(schedule), + action: mapAction(schedule), + status: mapStatus(schedule.status), + createdBy: summarizeCreatedBy(schedule), + rawSchedule: schedule, +}); + +const updateMappedSchedule = (schedules: ScheduleListItem[], schedule: Schedule) => + normalizeSchedules( + schedules.map((current) => (current.id === schedule.id.toString() ? mapSchedule(schedule) : current)), + ); + +export const useScheduleApi = () => { + const { handleAuthErrors } = useAuthErrors(); + const [schedules, setSchedules] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const inFlightRefreshRef = useRef | null>(null); + const foregroundRefreshCountRef = useRef(0); + + const runListSchedules = useCallback(() => { + if (inFlightRefreshRef.current) { + return inFlightRefreshRef.current; + } + + const requestPromise = (async () => { + try { + const scheduleResponse = await scheduleClient.listSchedules(create(ListSchedulesRequestSchema, {})); + const mappedSchedules = normalizeSchedules(scheduleResponse.schedules.map((schedule) => mapSchedule(schedule))); + + setSchedules(mappedSchedules); + return mappedSchedules; + } catch (error) { + const resolvedError = ensureError(error, "Failed to load schedules."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + })(); + + inFlightRefreshRef.current = requestPromise; + + void requestPromise.then( + () => { + if (inFlightRefreshRef.current === requestPromise) { + inFlightRefreshRef.current = null; + } + }, + () => { + if (inFlightRefreshRef.current === requestPromise) { + inFlightRefreshRef.current = null; + } + }, + ); + + return requestPromise; + }, [handleAuthErrors]); + + const listSchedules = useCallback( + async ({ background = false }: RefreshSchedulesOptions = {}) => { + if (background) { + return runListSchedules(); + } + + foregroundRefreshCountRef.current += 1; + setIsLoading(true); + + try { + return await runListSchedules(); + } finally { + foregroundRefreshCountRef.current = Math.max(0, foregroundRefreshCountRef.current - 1); + setIsLoading(foregroundRefreshCountRef.current > 0); + } + }, + [runListSchedules], + ); + + const refreshSchedules = useCallback( + async (options?: RefreshSchedulesOptions) => listSchedules(options), + [listSchedules], + ); + + const pauseSchedule = useCallback( + async (scheduleId: string) => { + try { + const response = await scheduleClient.pauseSchedule( + create(PauseScheduleRequestSchema, { scheduleId: BigInt(scheduleId) }), + ); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Paused schedule response was missing a schedule."); + } + + setSchedules((current) => updateMappedSchedule(current, nextSchedule)); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to pause schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const resumeSchedule = useCallback( + async (scheduleId: string) => { + try { + const response = await scheduleClient.resumeSchedule( + create(ResumeScheduleRequestSchema, { scheduleId: BigInt(scheduleId) }), + ); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Resumed schedule response was missing a schedule."); + } + + setSchedules((current) => updateMappedSchedule(current, nextSchedule)); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to resume schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const deleteSchedule = useCallback( + async (scheduleId: string) => { + try { + await scheduleClient.deleteSchedule(create(DeleteScheduleRequestSchema, { scheduleId: BigInt(scheduleId) })); + setSchedules((current) => normalizeSchedules(current.filter((schedule) => schedule.id !== scheduleId))); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to delete schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const reorderSchedules = useCallback( + async (scheduleIds: string[]) => { + try { + const request: ReorderSchedulesRequest = create(ReorderSchedulesRequestSchema, { + scheduleIds: scheduleIds.map((id) => BigInt(id)), + }); + + await scheduleClient.reorderSchedules(request); + + setSchedules((current) => { + const rank = new Map(scheduleIds.map((id, index) => [id, index])); + const fallbackRank = scheduleIds.length; + + return resequenceSchedules( + [...current].sort((left, right) => { + const leftRank = rank.get(left.id) ?? fallbackRank + left.priority; + const rightRank = rank.get(right.id) ?? fallbackRank + right.priority; + + return leftRank - rightRank; + }), + ); + }); + emitSchedulesChanged(); + } catch (error) { + const resolvedError = ensureError(error, "Failed to reorder schedules."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const createSchedule = useCallback( + async (request: CreateScheduleRequest) => { + try { + const response = await scheduleClient.createSchedule(request); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Created schedule response was missing a schedule."); + } + + const mappedSchedule = mapSchedule(nextSchedule); + setSchedules((current) => normalizeSchedules([...current, mappedSchedule])); + emitSchedulesChanged(); + return mappedSchedule; + } catch (error) { + const resolvedError = ensureError(error, "Failed to create schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + const updateSchedule = useCallback( + async (request: UpdateScheduleRequest) => { + try { + const response = await scheduleClient.updateSchedule(request); + const nextSchedule = response.schedule; + + if (!nextSchedule) { + throw new Error("Updated schedule response was missing a schedule."); + } + + setSchedules((current) => updateMappedSchedule(current, nextSchedule)); + emitSchedulesChanged(); + return mapSchedule(nextSchedule); + } catch (error) { + const resolvedError = ensureError(error, "Failed to update schedule."); + + handleAuthErrors({ + error, + onError: () => { + throw resolvedError; + }, + }); + + throw resolvedError; + } + }, + [handleAuthErrors], + ); + + return useMemo( + () => ({ + schedules, + isLoading, + listSchedules, + refreshSchedules, + createSchedule, + updateSchedule, + pauseSchedule, + resumeSchedule, + deleteSchedule, + reorderSchedules, + }), + [ + schedules, + isLoading, + listSchedules, + refreshSchedules, + createSchedule, + updateSchedule, + pauseSchedule, + resumeSchedule, + deleteSchedule, + reorderSchedules, + ], + ); +}; + +export type UseScheduleApiResult = ReturnType; + +export default useScheduleApi; diff --git a/client/src/protoFleet/api/useTelemetryMetrics.test.ts b/client/src/protoFleet/api/useTelemetryMetrics.test.ts new file mode 100644 index 000000000..68b0c4c05 --- /dev/null +++ b/client/src/protoFleet/api/useTelemetryMetrics.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { getGranularityForDuration } from "@/protoFleet/features/dashboard/utils/granularity"; +import { FleetDuration } from "@/shared/components/DurationSelector"; + +// Import the constants and function we need to test +// Since they're not exported, we'll need to test through the hook's behavior +// But for unit testing the logic, let's test the duration calculations directly + +describe("useTelemetryMetrics granularity calculations", () => { + // Helper to calculate expected bucket count + const calculateBucketCount = (durationSeconds: number, granularitySeconds: number): number => { + return Math.ceil(durationSeconds / granularitySeconds); + }; + + describe("duration to seconds conversion", () => { + it("converts 1h to 3600 seconds", () => { + const duration: FleetDuration = "1h"; + const seconds = parseInt(duration.slice(0, -1)) * 3600; + expect(seconds).toBe(3600); + }); + + it("converts 24h to 86400 seconds", () => { + const duration: FleetDuration = "24h"; + const seconds = parseInt(duration.slice(0, -1)) * 3600; + expect(seconds).toBe(86400); + }); + + it("converts 7d to 604800 seconds", () => { + const duration: FleetDuration = "7d"; + const seconds = parseInt(duration.slice(0, -1)) * 24 * 3600; + expect(seconds).toBe(604800); + }); + + it("converts 30d to 2592000 seconds", () => { + const duration: FleetDuration = "30d"; + const seconds = parseInt(duration.slice(0, -1)) * 24 * 3600; + expect(seconds).toBe(2592000); + }); + }); + + describe("granularity selection to stay within 1000 bucket limit", () => { + const BACKEND_BUCKET_LIMIT = 1000; + + it("uses 90s granularity for 1h (40 buckets)", () => { + const durationSeconds = 3600; // 1h + const granularity = 90; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(40); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + + it("uses 90s granularity for 24h (960 buckets)", () => { + const durationSeconds = 86400; // 24h + const granularity = 90; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(960); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + + it("uses 900s granularity for 7d (672 buckets)", () => { + const durationSeconds = 604800; // 7d + const granularity = 900; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(672); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + + it("uses 2700s (45min) granularity for 30d (960 buckets)", () => { + const durationSeconds = 2592000; // 30d + const granularity = 2700; + const buckets = calculateBucketCount(durationSeconds, granularity); + + expect(buckets).toBe(960); + expect(buckets).toBeLessThanOrEqual(BACKEND_BUCKET_LIMIT); + }); + }); + + describe("granularity would exceed limit with wrong values", () => { + it("7d with 600s granularity would exceed limit (1008 buckets)", () => { + const durationSeconds = 604800; // 7d + const wrongGranularity = 600; + const buckets = calculateBucketCount(durationSeconds, wrongGranularity); + + expect(buckets).toBe(1008); + expect(buckets).toBeGreaterThan(1000); // Would fail without optimization + }); + + it("30d with 90s granularity would exceed limit (28800 buckets)", () => { + const durationSeconds = 2592000; // 30d + const wrongGranularity = 90; + const buckets = calculateBucketCount(durationSeconds, wrongGranularity); + + expect(buckets).toBe(28800); + expect(buckets).toBeGreaterThan(1000); // Would fail without optimization + }); + }); + + describe("edge cases", () => { + it("keeps 7d granularity aligned with hourly aggregates", () => { + const granularity = getGranularityForDuration("7d"); + + expect(granularity).toBe(900); + expect(3600 % granularity).toBe(0); + }); + + it("invalid duration returns default granularity (90s)", () => { + // This tests that invalid durations fall back to 90s default + const defaultGranularity = 90; + expect(defaultGranularity).toBe(90); + }); + }); +}); diff --git a/client/src/protoFleet/api/useTelemetryMetrics.ts b/client/src/protoFleet/api/useTelemetryMetrics.ts new file mode 100644 index 000000000..5e12cb72f --- /dev/null +++ b/client/src/protoFleet/api/useTelemetryMetrics.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { telemetryClient } from "@/protoFleet/api/clients"; +import { + AggregationType, + DeviceListSchema, + DeviceSelectorSchema, + GetCombinedMetricsRequestSchema, + GetCombinedMetricsResponse, + MeasurementType, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { getGranularityForDuration } from "@/protoFleet/features/dashboard/utils/granularity"; +import { useAuthErrors } from "@/protoFleet/store"; +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; + +interface TelemetryMetricsOptions { + deviceIds?: string[]; + measurementTypes?: MeasurementType[]; + aggregations?: AggregationType[]; + duration: FleetDuration; + enabled?: boolean; + pollIntervalMs?: number; +} + +export const useTelemetryMetrics = (options: TelemetryMetricsOptions) => { + const { handleAuthErrors } = useAuthErrors(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + + const requestIdRef = useRef(0); + const hasLoadedRef = useRef(false); + + // Reset when scope changes — invalidate in-flight requests so stale responses can't land + const scopeKey = `${options.duration}-${options.deviceIds?.join(",") ?? "all"}`; + const prevScopeRef = useRef(scopeKey); + if (prevScopeRef.current !== scopeKey) { + prevScopeRef.current = scopeKey; + ++requestIdRef.current; + hasLoadedRef.current = false; + setHasLoaded(false); + setData(null); + } + + const fetchMetrics = useCallback(async () => { + if (!options.enabled) { + ++requestIdRef.current; + setIsLoading(false); + return; + } + + const thisRequestId = ++requestIdRef.current; + + // Only show loading spinner on first fetch, not poll refreshes + if (!hasLoadedRef.current) { + setIsLoading(true); + } + setError(null); + + try { + const now = new Date(); + const durationMs = getFleetDurationMs(options.duration); + const startTime = new Date(now.getTime() - durationMs); + + const request = create(GetCombinedMetricsRequestSchema, { + deviceSelector: options.deviceIds?.length + ? create(DeviceSelectorSchema, { + selectorValue: { + case: "deviceList", + value: create(DeviceListSchema, { + deviceIds: options.deviceIds, + }), + }, + }) + : create(DeviceSelectorSchema, { + selectorValue: { case: "allDevices", value: true }, + }), + measurementTypes: options.measurementTypes || [MeasurementType.HASHRATE], + aggregations: options.aggregations || [AggregationType.AVERAGE], + granularity: { seconds: BigInt(getGranularityForDuration(options.duration)), nanos: 0 }, + startTime: { + seconds: BigInt(Math.floor(startTime.getTime() / 1000)), + nanos: 0, + }, + endTime: { + seconds: BigInt(Math.floor(now.getTime() / 1000)), + nanos: 0, + }, + pageSize: 10000, + pageToken: "", + }); + + const response = await telemetryClient.getCombinedMetrics(request); + + // Discard stale responses + if (thisRequestId !== requestIdRef.current) return; + + setData(response); + hasLoadedRef.current = true; + setHasLoaded(true); + } catch (err) { + if (thisRequestId !== requestIdRef.current) return; + + handleAuthErrors({ + error: err, + onError: () => { + const errorObj = err instanceof Error ? err : new Error(String(err)); + setError(errorObj); + // Only clear data on first-load failure; preserve last snapshot during poll errors + if (!hasLoadedRef.current) { + setData(null); + } + console.error("Error fetching combined metrics:", errorObj); + }, + }); + } finally { + if (thisRequestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, [ + options.deviceIds, + options.measurementTypes, + options.aggregations, + options.duration, + options.enabled, + handleAuthErrors, + ]); + + // Initial fetch + refetch on dependency change + useEffect(() => { + fetchMetrics(); + }, [fetchMetrics]); + + // Polling + useEffect(() => { + if (!options.pollIntervalMs || !options.enabled) return; + + const intervalId = setInterval(() => { + void fetchMetrics(); + }, options.pollIntervalMs); + + return () => clearInterval(intervalId); + }, [options.pollIntervalMs, options.enabled, fetchMetrics]); + + return { data, isLoading, hasLoaded, error, refetch: fetchMetrics }; +}; diff --git a/client/src/protoFleet/api/useUpdateWorkerNames.test.ts b/client/src/protoFleet/api/useUpdateWorkerNames.test.ts new file mode 100644 index 000000000..0169924d1 --- /dev/null +++ b/client/src/protoFleet/api/useUpdateWorkerNames.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { fleetManagementClient } from "./clients"; +import useUpdateWorkerNames from "./useUpdateWorkerNames"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { SortConfigSchema, SortDirection, SortField } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + DeviceSelectorSchema, + MinerNameConfigSchema, + NamePropertySchema, + StringPropertySchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +vi.mock("./clients", () => ({ + fleetManagementClient: { + updateWorkerNames: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +describe("useUpdateWorkerNames", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends the expected request payload and wraps the sort in an array", async () => { + vi.mocked(fleetManagementClient.updateWorkerNames).mockResolvedValue({ updatedCount: 1 } as never); + + const deviceSelector = create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: ["miner-1", "miner-2"] }), + }, + }); + const nameConfig = create(MinerNameConfigSchema, { + properties: [ + create(NamePropertySchema, { + kind: { + case: "stringValue", + value: create(StringPropertySchema, { value: "worker-new" }), + }, + }), + ], + separator: "", + }); + const sort = create(SortConfigSchema, { + field: SortField.NAME, + direction: SortDirection.ASC, + }); + + const { result } = renderHook(() => useUpdateWorkerNames()); + + await act(async () => { + await result.current.updateWorkerNames(deviceSelector, nameConfig, "fleet-user", "fleet-pass", sort); + }); + + expect(fleetManagementClient.updateWorkerNames).toHaveBeenCalledWith( + expect.objectContaining({ + deviceSelector, + nameConfig, + sort: [sort], + userUsername: "fleet-user", + userPassword: "fleet-pass", + }), + ); + }); + + it("sends an empty sort array when no sort is provided", async () => { + vi.mocked(fleetManagementClient.updateWorkerNames).mockResolvedValue({ updatedCount: 1 } as never); + + const deviceSelector = create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: ["miner-1"] }), + }, + }); + const nameConfig = create(MinerNameConfigSchema, { + separator: "", + }); + + const { result } = renderHook(() => useUpdateWorkerNames()); + + await act(async () => { + await result.current.updateWorkerNames(deviceSelector, nameConfig, "fleet-user", "fleet-pass"); + }); + + expect(fleetManagementClient.updateWorkerNames).toHaveBeenCalledWith( + expect.objectContaining({ + sort: [], + }), + ); + }); + + it("handles auth errors and rethrows the original error", async () => { + const testError = new Error("request failed"); + vi.mocked(fleetManagementClient.updateWorkerNames).mockRejectedValue(testError); + + const deviceSelector = create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: ["miner-1"] }), + }, + }); + const nameConfig = create(MinerNameConfigSchema, { + separator: "", + }); + + const { result } = renderHook(() => useUpdateWorkerNames()); + + await expect( + result.current.updateWorkerNames(deviceSelector, nameConfig, "fleet-user", "fleet-pass"), + ).rejects.toThrow(testError); + expect(mockHandleAuthErrors).toHaveBeenCalledWith({ + error: testError, + }); + }); +}); diff --git a/client/src/protoFleet/api/useUpdateWorkerNames.ts b/client/src/protoFleet/api/useUpdateWorkerNames.ts new file mode 100644 index 000000000..ee9ebb97b --- /dev/null +++ b/client/src/protoFleet/api/useUpdateWorkerNames.ts @@ -0,0 +1,79 @@ +import { useCallback, useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { type SortConfig } from "@/protoFleet/api/generated/common/v1/sort_pb"; +import { + type DeviceSelector, + DeviceSelectorSchema, + type MinerNameConfig, + MinerNameConfigSchema, + NamePropertySchema, + StringPropertySchema, + UpdateWorkerNamesRequestSchema, + type UpdateWorkerNamesResponse, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useAuthErrors } from "@/protoFleet/store"; + +const useUpdateWorkerNames = () => { + const { handleAuthErrors } = useAuthErrors(); + + const updateWorkerNames = useCallback( + async ( + deviceSelector: DeviceSelector, + nameConfig: MinerNameConfig, + userUsername: string, + userPassword: string, + sort?: SortConfig, + ): Promise => { + try { + return await fleetManagementClient.updateWorkerNames( + create(UpdateWorkerNamesRequestSchema, { + deviceSelector, + nameConfig, + sort: sort ? [sort] : [], + userUsername, + userPassword, + }), + ); + } catch (err) { + handleAuthErrors({ + error: err, + }); + throw err; + } + }, + [handleAuthErrors], + ); + + const updateSingleWorkerName = useCallback( + async (deviceIdentifier: string, name: string, userUsername: string, userPassword: string) => + updateWorkerNames( + create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { deviceIdentifiers: [deviceIdentifier] }), + }, + }), + create(MinerNameConfigSchema, { + properties: [ + create(NamePropertySchema, { + kind: { + case: "stringValue", + value: create(StringPropertySchema, { value: name }), + }, + }), + ], + separator: "", + }), + userUsername, + userPassword, + ), + [updateWorkerNames], + ); + + return useMemo(() => ({ updateWorkerNames, updateSingleWorkerName }), [updateSingleWorkerName, updateWorkerNames]); +}; + +export default useUpdateWorkerNames; diff --git a/client/src/protoFleet/api/useUserManagement.ts b/client/src/protoFleet/api/useUserManagement.ts new file mode 100644 index 000000000..340bc412f --- /dev/null +++ b/client/src/protoFleet/api/useUserManagement.ts @@ -0,0 +1,163 @@ +import { useCallback } from "react"; + +import { authClient } from "@/protoFleet/api/clients"; +import type { + CreateUserRequest, + DeactivateUserRequest, + ResetUserPasswordRequest, +} from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface CreateUserProps { + username: CreateUserRequest["username"]; + onSuccess?: (userId: string, username: string, tempPassword: string) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ListUsersProps { + onSuccess?: ( + users: Array<{ + userId: string; + username: string; + passwordUpdatedAt: Date | null; + lastLoginAt: Date | null; + role: string; + requiresPasswordChange: boolean; + }>, + ) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface ResetUserPasswordProps { + userId: ResetUserPasswordRequest["userId"]; + onSuccess?: (tempPassword: string) => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +interface DeactivateUserProps { + userId: DeactivateUserRequest["userId"]; + onSuccess?: () => void; + onError?: (message: string) => void; + onFinally?: () => void; +} + +const useUserManagement = () => { + const { handleAuthErrors } = useAuthErrors(); + + const createUser = useCallback( + async ({ username, onSuccess, onError, onFinally }: CreateUserProps) => { + await authClient + .createUser({ username }) + .then((response) => { + onSuccess?.(response.userId, response.username, response.temporaryPassword); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const listUsers = useCallback( + async ({ onSuccess, onError, onFinally }: ListUsersProps) => { + await authClient + .listUsers({}) + .then((response) => { + const users = response.users.map((user) => ({ + userId: user.userId, + username: user.username, + passwordUpdatedAt: + user.passwordUpdatedAt && user.passwordUpdatedAt.seconds > 0 + ? new Date(Number(user.passwordUpdatedAt.seconds) * 1000) + : null, + lastLoginAt: + user.lastLoginAt && user.lastLoginAt.seconds > 0 + ? new Date(Number(user.lastLoginAt.seconds) * 1000) + : null, + role: user.role, + requiresPasswordChange: user.requiresPasswordChange, + })); + onSuccess?.(users); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const resetUserPassword = useCallback( + async ({ userId, onSuccess, onError, onFinally }: ResetUserPasswordProps) => { + await authClient + .resetUserPassword({ userId }) + .then((response) => { + onSuccess?.(response.temporaryPassword); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + const deactivateUser = useCallback( + async ({ userId, onSuccess, onError, onFinally }: DeactivateUserProps) => { + await authClient + .deactivateUser({ userId }) + .then(() => { + onSuccess?.(); + }) + .catch((err) => { + handleAuthErrors({ + error: err, + onError: () => { + onError?.(getErrorMessage(err)); + }, + }); + }) + .finally(() => { + onFinally?.(); + }); + }, + [handleAuthErrors], + ); + + return { + createUser, + listUsers, + resetUserPassword, + deactivateUser, + }; +}; + +export type UseUserManagementReturn = ReturnType; + +export { useUserManagement }; diff --git a/client/src/protoFleet/components/App/App.test.tsx b/client/src/protoFleet/components/App/App.test.tsx new file mode 100644 index 000000000..a51f9a017 --- /dev/null +++ b/client/src/protoFleet/components/App/App.test.tsx @@ -0,0 +1,255 @@ +import { ReactNode } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import App from "./App"; +import { FleetOnboardingStatus } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; +// TODO: Update this test to work with Zustand store instead of React Context + +// Mock the API call for onboarding status +let mockedOnboardingStatus: FleetOnboardingStatus | null = null; +vi.mock("@/protoFleet/api/useOnboardedStatus", () => ({ + useOnboardedStatus: vi.fn(() => ({ + poolConfigured: mockedOnboardingStatus?.poolConfigured ?? false, + devicePaired: mockedOnboardingStatus?.devicePaired ?? false, + statusLoaded: true, + refetch: vi.fn(() => Promise.resolve(mockedOnboardingStatus)), + })), +})); + +// Mock AppLayout component for UI testing +vi.mock("@/protoFleet/components/AppLayout", () => ({ + default: ({ children }: { children: ReactNode }) => ( +
+
App Layout Header
+ {children} +
+ ), +})); + +vi.mock("@/protoFleet/routes", () => ({ + getRouteMetadata: vi.fn((pathname) => ({ + title: pathname === "/auth" ? "Auth" : pathname.includes("onboarding") ? "Onboarding" : "Home", + requireAuth: pathname !== "/auth" && !pathname.includes("/welcome"), + useAppLayout: !pathname.includes("/auth") && !pathname.includes("/onboarding"), + })), +})); + +// Global test state for auth token validity +// TODO: Re-enable when tests are updated to work with Zustand +// let isValidToken = true; + +// TODO: Update this test to work with Zustand store instead of React Context +describe.skip("App", () => { + const createRoutes = () => [ + { + path: "/", + element: , + children: [ + { + index: true, + element:
Home Page Content
, + }, + { + path: "auth", + element:
Auth Page Content
, + }, + { + path: "miners", + element:
Miners Page Content
, + }, + { + path: "welcome", + element:
Landing Page Content
, + }, + { + path: "onboarding/miners", + element:
Miners Onboarding Page
, + }, + { + path: "onboarding/mining-pool", + element:
Mining Pool Page Content
, + }, + ], + }, + ]; + + // Setup function to render the app with a router + const renderWithRouter = (initialPath = "/") => { + const router = createMemoryRouter(createRoutes(), { + initialEntries: [initialPath], + }); + + // TODO: Create the auth context with the test token state + // This needs to be updated to work with Zustand store + return render( + // + , + // , + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + // isValidToken = true; + mockedOnboardingStatus = null; + }); + + describe("Authentication routing", () => { + test("should allow access to protected routes with valid token", async () => { + // isValidToken = true; + renderWithRouter("/"); + + // Should show home page with valid token + await waitFor(() => { + expect(screen.getByTestId("home-page")).toBeInTheDocument(); + }); + + // Home page should use AppLayout + expect(screen.getByTestId("app-layout")).toBeInTheDocument(); + }); + + test("should redirect to auth page with invalid token", async () => { + // isValidToken = false; + renderWithRouter("/"); + + // Should redirect to auth page with invalid token + await waitFor(() => { + expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + }); + }); + + test("should always allow access to auth page regardless of token", async () => { + // isValidToken = false; + renderWithRouter("/auth"); + + // Should not redirect when already on auth page + await waitFor(() => { + expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + }); + }); + }); + + // describe("Onboarding routing", () => { + // test("should redirect to miners onboarding when devicePaired is false", async () => { + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: false, + // poolConfigured: false, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should redirect to onboarding/miners + // await waitFor(() => { + // expect( + // screen.getByTestId("onboarding-miners-page"), + // ).toBeInTheDocument(); + // }); + // }); + + // test("should redirect to mining-pool onboarding when poolConfigured is false", async () => { + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: true, + // poolConfigured: false, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should redirect to onboarding/mining-pool + // await waitFor(() => { + // expect(screen.getByTestId("mining-pool-page")).toBeInTheDocument(); + // }); + // }); + + // test("should not redirect when onboarding is complete", async () => { + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: true, + // poolConfigured: true, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should remain on home page + // await waitFor(() => { + // expect(screen.getByTestId("home-page")).toBeInTheDocument(); + // }); + // }); + + // test("should not redirect when onboarding status is still loading", async () => { + // isValidToken = true; + // mockedOnboardingStatus = null; // Loading state + + // renderWithRouter("/"); + + // // Should remain on home page + // await waitFor(() => { + // expect(screen.getByTestId("home-page")).toBeInTheDocument(); + // }); + // }); + // }); + + // describe("Combined auth and onboarding behavior", () => { + // test("should prioritize auth redirect over onboarding redirect", async () => { + // isValidToken = false; + // mockedOnboardingStatus = { + // devicePaired: false, + // poolConfigured: true, + // } as FleetOnboardingStatus; + + // renderWithRouter("/"); + + // // Should redirect to auth page, not to onboarding page + // await waitFor(() => { + // expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + // }); + // }); + + // test("should process onboarding after successful auth", async () => { + // // First render with invalid token + // isValidToken = false; + // renderWithRouter("/"); + + // // Should redirect to auth + // await waitFor(() => { + // expect(screen.getByTestId("auth-page")).toBeInTheDocument(); + // }); + + // // Now simulate login success and onboarding check + // isValidToken = true; + // mockedOnboardingStatus = { + // devicePaired: false, + // poolConfigured: false, + // } as FleetOnboardingStatus; + + // // Re-render with the updated state + // renderWithRouter("/"); + + // // Should now redirect to onboarding + // await waitFor(() => { + // expect( + // screen.getByTestId("onboarding-miners-page"), + // ).toBeInTheDocument(); + // }); + // }); + // }); +}); diff --git a/client/src/protoFleet/components/App/App.tsx b/client/src/protoFleet/components/App/App.tsx new file mode 100644 index 000000000..22a3c0c35 --- /dev/null +++ b/client/src/protoFleet/components/App/App.tsx @@ -0,0 +1,129 @@ +import { ReactNode, useEffect, useMemo, useRef } from "react"; +import { useMatches } from "react-router-dom"; +import clsx from "clsx"; + +import { onboardingClient } from "@/protoFleet/api/clients"; +import AppLayout from "@/protoFleet/components/AppLayout"; +import { requiresAuth } from "@/protoFleet/router"; +import { useCheckAuthentication, useIsActionBarVisible } from "@/protoFleet/store"; +import { useDeviceTheme, useSetDeviceTheme, useTheme } from "@/protoFleet/store"; +import ErrorBoundary from "@/shared/components/ErrorBoundary"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { useApplyTheme } from "@/shared/features/preferences"; +import { Toaster } from "@/shared/features/toaster"; +import { isBackendDownError } from "@/shared/utils/backendHealth"; +import { redirectFromFleetDown } from "@/shared/utils/fleetDownRedirect"; + +interface AppProps { + children?: ReactNode; + fullscreen?: boolean; +} + +const App = ({ children, fullscreen }: AppProps) => { + // ============================================================================ + // BACKEND HEALTH CHECK + // ============================================================================ + const healthCheckDone = useRef(false); + + useEffect(() => { + // Only run health check once on initial mount + if (healthCheckDone.current) return; + healthCheckDone.current = true; + + const isOnFleetDownErrorPage = window.location.pathname === "/fleet-down"; + let isMounted = true; + + // Check if backend is available by making a lightweight API call + const checkBackendHealth = async () => { + try { + await onboardingClient.getFleetInitStatus({}); + + // If backend is up and we're on the error page, redirect back to app + if (isOnFleetDownErrorPage && isMounted) { + redirectFromFleetDown(); + } + } catch (error: unknown) { + // Only redirect to error page if backend is down AND not already on error page + if (isBackendDownError(error) && !isOnFleetDownErrorPage && isMounted) { + const currentPath = window.location.pathname + window.location.search + window.location.hash; + window.location.href = `/fleet-down?from=${encodeURIComponent(currentPath)}`; + } + } + }; + + checkBackendHealth(); + + return () => { + isMounted = false; + }; + }, []); + + // ============================================================================ + // THEME APPLICATION + // ============================================================================ + const theme = useTheme(); + const deviceTheme = useDeviceTheme(); + const setDeviceTheme = useSetDeviceTheme(); + + // Apply theme effects on mount + useApplyTheme({ theme, deviceTheme, setDeviceTheme }); + + // ============================================================================ + // AUTH CHECKING + // ============================================================================ + const matches = useMatches(); + const currentPath = useMemo(() => { + return matches[matches.length - 1]?.pathname || "/"; + }, [matches]); + + const requireAuth = useMemo(() => { + // Check if this specific path is configured to not require auth + // If not in the config, default to requiring auth + return requiresAuth[currentPath] !== false; + }, [currentPath]); + + const { loading, hasAccess } = useCheckAuthentication(requireAuth); + + const isActionBarVisible = useIsActionBarVisible(); + + // Show loading spinner ONLY if auth is required AND (loading OR access denied) + const showLoading = requireAuth && (loading || hasAccess !== true); + + // ============================================================================ + // LOADING STATE + // ============================================================================ + if (showLoading) { + return ( +
+ +
+ ); + } + + // ============================================================================ + // RENDER + // ============================================================================ + return ( + + {/* Toaster - Fixed position, renders above overlays (z-50) and dialogs (z-40) */} +
+ +
+ + {fullscreen ? ( + // Fullscreen mode: Just render children without AppLayout chrome + children + ) : ( + // Normal mode: Render with AppLayout + {children} + )} +
+ ); +}; + +export default App; diff --git a/client/src/protoFleet/components/App/index.ts b/client/src/protoFleet/components/App/index.ts new file mode 100644 index 000000000..7078f6de4 --- /dev/null +++ b/client/src/protoFleet/components/App/index.ts @@ -0,0 +1,3 @@ +import App from "./App"; + +export default App; diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx new file mode 100644 index 000000000..9da54785f --- /dev/null +++ b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import AppLayout from "./AppLayout"; +import type { UseSchedulePillDataResult } from "@/protoFleet/components/PageHeader/useSchedulePillData"; + +const mockUseWindowDimensions = vi.fn(); +const mockUseReactiveLocalStorage = vi.fn(); +const mockUseSchedulePillData = vi.fn(); + +vi.mock("@/protoFleet/api/ScheduleApiProvider", () => ({ + ScheduleApiProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/protoFleet/components/NavigationMenu", () => ({ + __esModule: true, + default: () =>
Navigation menu
, +})); + +vi.mock("@/protoFleet/components/PageHeader", () => ({ + __esModule: true, + default: () =>
Page header
, +})); + +vi.mock("@/protoFleet/components/PageHeader/useSchedulePillData", () => ({ + useSchedulePillData: () => mockUseSchedulePillData(), +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: () => mockUseWindowDimensions(), +})); + +vi.mock("@/shared/hooks/useReactiveLocalStorage", () => ({ + useReactiveLocalStorage: () => mockUseReactiveLocalStorage(), +})); + +const createSchedulePillData = (overrides: Partial = {}): UseSchedulePillDataResult => ({ + hasVisibleSchedules: false, + pillSchedule: null, + sections: [], + pendingScheduleId: null, + onToggleScheduleStatus: vi.fn(), + ...overrides, +}); + +describe("AppLayout", () => { + beforeEach(() => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: true, + }); + mockUseReactiveLocalStorage.mockReturnValue([false, vi.fn()]); + mockUseSchedulePillData.mockReturnValue(createSchedulePillData()); + }); + + it("offsets the phone content when schedules make the header widgets visible", () => { + mockUseSchedulePillData.mockReturnValue( + createSchedulePillData({ + hasVisibleSchedules: true, + }), + ); + + render( + + +
Body content
+
+
, + ); + + expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+57px)]"); + }); +}); diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.tsx new file mode 100644 index 000000000..3b42eddde --- /dev/null +++ b/client/src/protoFleet/components/AppLayout/AppLayout.tsx @@ -0,0 +1,59 @@ +import { ReactNode, useState } from "react"; +import clsx from "clsx"; + +import NavigationMenu from "../NavigationMenu"; +import { ScheduleApiProvider } from "@/protoFleet/api/ScheduleApiProvider"; +import PageHeader from "@/protoFleet/components/PageHeader"; +import { useSchedulePillData } from "@/protoFleet/components/PageHeader/useSchedulePillData"; +import { primaryNavItems } from "@/protoFleet/config/navItems"; +import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; +import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +type Props = { + children: ReactNode; +}; + +const AppLayoutContent = ({ children }: Props) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { bgClass } = usePageBackground(); + const { isPhone } = useWindowDimensions(); + const [dismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); + const schedulePillData = useSchedulePillData(); + const hasDismissedSetup = Boolean(dismissedSetup); + + const showPhoneWidgets = isPhone && (hasDismissedSetup || schedulePillData.hasVisibleSchedules); + + return ( +
+
+ setIsMenuOpen(false)} /> +
+ +
+ setIsMenuOpen(true)} schedulePillData={schedulePillData} /> +
+ +
+ {children} +
+
+ ); +}; + +const AppLayout = (props: Props) => ( + + + +); + +export default AppLayout; diff --git a/client/src/protoFleet/components/AppLayout/index.ts b/client/src/protoFleet/components/AppLayout/index.ts new file mode 100644 index 000000000..fa6be263a --- /dev/null +++ b/client/src/protoFleet/components/AppLayout/index.ts @@ -0,0 +1,3 @@ +import AppLayout from "./AppLayout"; + +export default AppLayout; diff --git a/client/src/protoFleet/components/DeviceSetList/DeviceSetList.test.tsx b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.test.tsx new file mode 100644 index 000000000..674270d08 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.test.tsx @@ -0,0 +1,210 @@ +import { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; + +import DeviceSetList from "./DeviceSetList"; +import type { DeviceSetListItem } from "./DeviceSetList"; +import { DeviceSetSchema, DeviceSetStatsSchema } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import type { DeviceSet, DeviceSetStats } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import NoFilterResultsEmptyState from "@/protoFleet/components/NoFilterResultsEmptyState"; + +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + LineChart: ({ children }: { children: ReactNode }) =>
{children}
, + ReferenceLine: () =>
, + Line: () =>
, + XAxis: () =>
, + YAxis: () =>
, +})); + +const createMockDeviceSet = (id: bigint, label: string): DeviceSet => + create(DeviceSetSchema, { + id, + label, + deviceCount: 5, + typeDetails: { case: "groupInfo", value: {} }, + }); + +const createMockStats = (deviceSetId: bigint): DeviceSetStats => + create(DeviceSetStatsSchema, { + deviceSetId, + deviceCount: 5, + reportingCount: 5, + totalHashrateThs: 100, + avgEfficiencyJth: 25, + totalPowerKw: 10, + minTemperatureC: 30, + maxTemperatureC: 60, + hashingCount: 4, + brokenCount: 1, + offlineCount: 0, + sleepingCount: 0, + hashrateReportingCount: 5, + efficiencyReportingCount: 5, + powerReportingCount: 5, + temperatureReportingCount: 5, + }); + +const defaultProps = { + renderName: (item: DeviceSetListItem) => {item.deviceSet.label}, + renderMiners: (item: DeviceSetListItem) => {item.deviceSet.deviceCount}, + currentSort: { field: "name" as const, direction: "asc" as const }, + onSort: vi.fn(), + itemName: { singular: "group", plural: "groups" }, +}; + +describe("DeviceSetList", () => { + it("uses descending sort when the issues header is selected", () => { + const deviceSet = createMockDeviceSet(1n, "Group A"); + const stats = createMockStats(1n); + const onSort = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Issues" })); + expect(onSort).toHaveBeenCalledWith("issues", "desc"); + }); + + describe("emptyStateRow prop", () => { + it("renders empty state row when items are empty and emptyStateRow is provided", () => { + render( + No matching items
} + />, + ); + + expect(screen.getByTestId("list-empty-row")).toBeInTheDocument(); + expect(screen.getByText("No matching items")).toBeInTheDocument(); + }); + + it("does not render empty state row when items are present", () => { + const deviceSet = createMockDeviceSet(1n, "Group A"); + const stats = createMockStats(1n); + + render( + No matching items
} + />, + ); + + expect(screen.queryByTestId("list-empty-row")).not.toBeInTheDocument(); + expect(screen.getByText("Group A")).toBeInTheDocument(); + }); + + it("does not render empty state row when items are empty and emptyStateRow is undefined", () => { + render(); + + expect(screen.queryByTestId("list-empty-row")).not.toBeInTheDocument(); + }); + + it("keeps column headers visible when showing empty state row", () => { + render( + No matching items
} + />, + ); + + expect(screen.getByTestId("list-header")).toBeInTheDocument(); + expect(screen.getByText("Name")).toBeInTheDocument(); + }); + }); + + describe("no results empty state content", () => { + const renderEmptyState = (onClearFilters: () => void) => ( + + ); + + it("renders 'No results' heading in the empty state", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("renders description text in the empty state", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Try adjusting or clearing your filters.")).toBeInTheDocument(); + }); + + it("renders the 'Clear all filters' button in the empty state", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + expect(screen.getByTestId("clear-all-filters-button")).toBeInTheDocument(); + expect(screen.getByText("Clear all filters")).toBeInTheDocument(); + }); + + it("calls the clear filters handler when 'Clear all filters' button is clicked", () => { + const handleClearFilters = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("clear-all-filters-button")); + expect(handleClearFilters).toHaveBeenCalledTimes(1); + }); + }); + + describe("pagination visibility with empty state", () => { + it("does not render pagination when items are empty and empty state is shown", () => { + render( + No results
} + />, + ); + + expect(screen.queryByLabelText("Previous page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Next page")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/protoFleet/components/DeviceSetList/DeviceSetList.tsx b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.tsx new file mode 100644 index 000000000..27d65e360 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/DeviceSetList.tsx @@ -0,0 +1,146 @@ +import { type ReactNode, useCallback, useMemo, useRef } from "react"; + +import { DEFAULT_PAGE_SIZE, deviceSetColTitles, type DeviceSetColumn } from "./constants"; +import { createDeviceSetColConfig } from "./deviceSetColConfig"; +import { getDefaultSortDirection, SORTABLE_COLUMNS } from "./sortConfig"; +import type { DeviceSet, DeviceSetStats } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { useTemperatureUnit } from "@/protoFleet/store"; +import { ChevronDown } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import List from "@/shared/components/List"; +import { type SortDirection } from "@/shared/components/List/types"; + +export type DeviceSetListItem = { + id: string; + deviceSet: DeviceSet; + stats?: DeviceSetStats; +}; + +const DEFAULT_ACTIVE_COLS: DeviceSetColumn[] = [ + "name", + "miners", + "issues", + "hashrate", + "efficiency", + "power", + "temperature", + "health", +]; + +type DeviceSetListProps = { + deviceSets: DeviceSet[]; + statsMap: Map; + renderName: (item: DeviceSetListItem) => ReactNode; + renderMiners: (item: DeviceSetListItem) => ReactNode; + currentSort: { field: DeviceSetColumn; direction: SortDirection }; + onSort: (field: DeviceSetColumn, direction: SortDirection) => void; + itemName: { singular: string; plural: string }; + columns?: DeviceSetColumn[]; + loading?: boolean; + total?: number; + pageSize?: number; + currentPage?: number; + hasPreviousPage?: boolean; + hasNextPage?: boolean; + onNextPage?: () => void; + onPrevPage?: () => void; + onRowClick?: (item: DeviceSetListItem, index: number) => void; + emptyStateRow?: ReactNode; +}; + +const DeviceSetList = ({ + deviceSets, + statsMap, + renderName, + renderMiners, + currentSort, + onSort, + itemName, + columns = DEFAULT_ACTIVE_COLS, + loading, + total, + pageSize = DEFAULT_PAGE_SIZE, + currentPage = 0, + hasPreviousPage = false, + hasNextPage = false, + onNextPage, + onPrevPage, + onRowClick, + emptyStateRow, +}: DeviceSetListProps) => { + const topRef = useRef(null); + const temperatureUnit = useTemperatureUnit(); + + const items: DeviceSetListItem[] = useMemo( + () => deviceSets.map((deviceSet) => ({ id: String(deviceSet.id), deviceSet, stats: statsMap.get(deviceSet.id) })), + [deviceSets, statsMap], + ); + + const colConfig = useMemo( + () => createDeviceSetColConfig({ renderName, renderMiners, temperatureUnit }), + [renderName, renderMiners, temperatureUnit], + ); + + const handleNextPage = useCallback(() => { + onNextPage?.(); + topRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [onNextPage]); + + const handlePrevPage = useCallback(() => { + onPrevPage?.(); + topRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [onPrevPage]); + + const firstItemIndex = currentPage * pageSize + 1; + const lastItemIndex = currentPage * pageSize + deviceSets.length; + const shouldRenderPagination = !loading && total !== undefined && total > 0; + + return ( + <> +
+ + activeCols={columns} + colTitles={deviceSetColTitles} + colConfig={colConfig} + items={items} + itemKey="id" + hideTotal + overflowContainer={false} + sortableColumns={SORTABLE_COLUMNS} + currentSort={currentSort} + onSort={onSort} + getDefaultSortDirection={getDefaultSortDirection} + onRowClick={onRowClick} + emptyStateRow={emptyStateRow} + /> + + {shouldRenderPagination && ( +
+ + Showing {firstItemIndex}–{lastItemIndex} of {total} {itemName.plural} + +
+
+
+ )} + + ); +}; + +export default DeviceSetList; diff --git a/client/src/protoFleet/components/DeviceSetList/StatCell.tsx b/client/src/protoFleet/components/DeviceSetList/StatCell.tsx new file mode 100644 index 000000000..1b982486b --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/StatCell.tsx @@ -0,0 +1,66 @@ +import { type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import { Info } from "@/shared/assets/icons"; +import { useFloatingPosition } from "@/shared/hooks/useFloatingPosition"; + +const InfoTooltip = ({ heading, body }: { heading: string; body: string }) => { + const { triggerRef, floatingStyle, isVisible, show, hide } = useFloatingPosition({ + placement: "bottom-end", + gap: 8, + minWidth: 320, + }); + + return ( + <> + + {isVisible && + createPortal( +
+
{heading}
+
{body}
+
, + document.body, + )} + + ); +}; + +const StatCell = ({ + metricReportingCount, + deviceCount, + children, +}: { + metricReportingCount: number; + deviceCount: number; + children: ReactNode; +}) => { + if (metricReportingCount >= deviceCount) return <>{children}; + + return ( +
+ {children} + +
+ ); +}; + +export default StatCell; diff --git a/client/src/protoFleet/components/DeviceSetList/constants.ts b/client/src/protoFleet/components/DeviceSetList/constants.ts new file mode 100644 index 000000000..bca41fb11 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/constants.ts @@ -0,0 +1,29 @@ +import type { ColTitles } from "@/shared/components/List/types"; + +export const deviceSetCols = { + name: "name", + zone: "zone", + miners: "miners", + issues: "issues", + hashrate: "hashrate", + efficiency: "efficiency", + power: "power", + temperature: "temperature", + health: "health", +} as const; + +export type DeviceSetColumn = (typeof deviceSetCols)[keyof typeof deviceSetCols]; + +export const deviceSetColTitles: ColTitles = { + name: "Name", + zone: "Zone", + miners: "Miners", + issues: "Issues", + hashrate: "Total Hashrate", + efficiency: "Avg Efficiency", + power: "Total Power", + temperature: "Temperature", + health: "Health", +}; + +export const DEFAULT_PAGE_SIZE = 50; diff --git a/client/src/protoFleet/components/DeviceSetList/deviceSetColConfig.tsx b/client/src/protoFleet/components/DeviceSetList/deviceSetColConfig.tsx new file mode 100644 index 000000000..11753a724 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/deviceSetColConfig.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from "react"; + +import { deviceSetCols, type DeviceSetColumn } from "./constants"; +import type { DeviceSetListItem } from "./DeviceSetList"; +import StatCell from "./StatCell"; +import CompositionBar, { type Segment } from "@/shared/components/CompositionBar"; +import { type ColConfig } from "@/shared/components/List/types"; +import type { TemperatureUnit } from "@/shared/features/preferences"; +import { getDisplayValue } from "@/shared/utils/stringUtils"; +import { formatTempRange } from "@/shared/utils/utility"; + +const INACTIVE_PLACEHOLDER = "—"; + +const HEALTH_COLOR_MAP = { + OK: "bg-core-primary-fill", + CRITICAL: "bg-intent-critical-fill", + NA: "bg-core-accent-fill", +}; + +type CreateDeviceSetColConfigParams = { + renderName: (item: DeviceSetListItem) => ReactNode; + renderMiners: (item: DeviceSetListItem) => ReactNode; + temperatureUnit: TemperatureUnit; +}; + +const createDeviceSetColConfig = ({ + renderName, + renderMiners, + temperatureUnit, +}: CreateDeviceSetColConfigParams): ColConfig => ({ + [deviceSetCols.name]: { + component: (item: DeviceSetListItem) => renderName(item), + width: "min-w-44", + }, + [deviceSetCols.zone]: { + component: (item: DeviceSetListItem) => { + if (item.deviceSet.typeDetails.case !== "rackInfo") return {INACTIVE_PLACEHOLDER}; + return {item.deviceSet.typeDetails.value.zone || INACTIVE_PLACEHOLDER}; + }, + width: "min-w-28", + }, + [deviceSetCols.miners]: { + component: (item: DeviceSetListItem) => renderMiners(item), + width: "min-w-20", + }, + [deviceSetCols.issues]: { + component: (item: DeviceSetListItem) => { + if (!item.stats) return {INACTIVE_PLACEHOLDER}; + const count = + item.stats.controlBoardIssueCount + + item.stats.fanIssueCount + + item.stats.hashBoardIssueCount + + item.stats.psuIssueCount; + if (count === 0) return 0; + return {count}; + }, + width: "min-w-20", + }, + [deviceSetCols.hashrate]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.hashrateReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return {getDisplayValue(item.stats.totalHashrateThs)} TH/s; + }, + width: "min-w-28", + }, + [deviceSetCols.efficiency]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.efficiencyReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return ( + + {getDisplayValue(item.stats.avgEfficiencyJth)} J/TH + + ); + }, + width: "min-w-28", + }, + [deviceSetCols.power]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.powerReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return ( + + {getDisplayValue(item.stats.totalPowerKw)} kW + + ); + }, + width: "min-w-24", + }, + [deviceSetCols.temperature]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.temperatureReportingCount === 0) return {INACTIVE_PLACEHOLDER}; + return {formatTempRange(item.stats.minTemperatureC, item.stats.maxTemperatureC, temperatureUnit)}; + }, + width: "min-w-28", + }, + [deviceSetCols.health]: { + component: (item: DeviceSetListItem) => { + if (!item.stats || item.stats.deviceCount === 0) return {INACTIVE_PLACEHOLDER}; + const { hashingCount, brokenCount, offlineCount, sleepingCount } = item.stats; + const segments: Segment[] = [ + { name: "Healthy", status: "OK", count: hashingCount }, + { name: "Needs Attention", status: "CRITICAL", count: brokenCount }, + { name: "Offline", status: "NA", count: offlineCount + sleepingCount }, + ]; + + return ( +
+ +
+ ); + }, + width: "min-w-32", + }, +}); + +export { createDeviceSetColConfig }; diff --git a/client/src/protoFleet/components/DeviceSetList/index.ts b/client/src/protoFleet/components/DeviceSetList/index.ts new file mode 100644 index 000000000..acae4c166 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/index.ts @@ -0,0 +1,4 @@ +export { default as DeviceSetList } from "./DeviceSetList"; +export type { DeviceSetListItem } from "./DeviceSetList"; +export { deviceSetCols, type DeviceSetColumn, DEFAULT_PAGE_SIZE } from "./constants"; +export { issueOptions, useIssueFilter } from "./issueFilterConstants"; diff --git a/client/src/protoFleet/components/DeviceSetList/issueFilterConstants.ts b/client/src/protoFleet/components/DeviceSetList/issueFilterConstants.ts new file mode 100644 index 000000000..930317d8a --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/issueFilterConstants.ts @@ -0,0 +1,30 @@ +import { useCallback, useRef } from "react"; + +import { ComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { componentIssues } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; + +export const issueOptions = [ + { id: componentIssues.controlBoard, label: "Control Board" }, + { id: componentIssues.fans, label: "Fan" }, + { id: componentIssues.hashBoards, label: "Hash Board" }, + { id: componentIssues.psu, label: "PSU" }, +]; + +export const ISSUE_TO_COMPONENT_TYPE: Record = { + [componentIssues.controlBoard]: ComponentType.CONTROL_BOARD, + [componentIssues.fans]: ComponentType.FAN, + [componentIssues.hashBoards]: ComponentType.HASH_BOARD, + [componentIssues.psu]: ComponentType.PSU, +}; + +export function useIssueFilter() { + const selectedIssuesRef = useRef([]); + + const getErrorComponentTypes = useCallback((): number[] => { + return selectedIssuesRef.current + .map((issue) => ISSUE_TO_COMPONENT_TYPE[issue]) + .filter((ct): ct is ComponentType => ct !== undefined); + }, []); + + return { selectedIssuesRef, getErrorComponentTypes }; +} diff --git a/client/src/protoFleet/components/DeviceSetList/sortConfig.test.ts b/client/src/protoFleet/components/DeviceSetList/sortConfig.test.ts new file mode 100644 index 000000000..7130ec777 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/sortConfig.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { getNextSortFromSelection } from "./sortConfig"; +import { SORT_ASC, SORT_DESC } from "@/shared/components/List/types"; + +type SortState = Parameters[1]; + +describe("getNextSortFromSelection", () => { + it("uses ascending when selecting miners from the dropdown", () => { + const currentSort: SortState = { + field: "name", + direction: SORT_ASC, + }; + + expect(getNextSortFromSelection(["miners"], currentSort)).toEqual({ + field: "miners", + direction: SORT_ASC, + }); + }); + + it("uses descending when selecting issues from the dropdown", () => { + const currentSort: SortState = { + field: "name", + direction: SORT_ASC, + }; + + expect(getNextSortFromSelection(["issues"], currentSort)).toEqual({ + field: "issues", + direction: SORT_DESC, + }); + }); + + it("toggles the current sort when the selection is invalid", () => { + const currentSort: SortState = { + field: "name", + direction: SORT_ASC, + }; + + expect(getNextSortFromSelection(["not-a-real-column"], currentSort)).toEqual({ + field: "name", + direction: SORT_DESC, + }); + }); +}); diff --git a/client/src/protoFleet/components/DeviceSetList/sortConfig.ts b/client/src/protoFleet/components/DeviceSetList/sortConfig.ts new file mode 100644 index 000000000..de5e53480 --- /dev/null +++ b/client/src/protoFleet/components/DeviceSetList/sortConfig.ts @@ -0,0 +1,85 @@ +import type { DeviceSetColumn } from "./constants"; +import { deviceSetCols } from "./constants"; +import { SORT_ASC, SORT_DESC, type SortDirection } from "@/shared/components/List/types"; + +type DeviceSetSortConfig = { + defaultDirection: SortDirection; +}; + +type DeviceSetSortState = { + field: DeviceSetColumn; + direction: SortDirection; +}; + +type DeviceSetSortOption = { + id: DeviceSetColumn; + label: string; +}; + +// Only fields backed by the list query are sortable. Telemetry-based columns still +// cannot be sorted globally across pages because they are fetched separately. +const SORT_CONFIG: Partial> = { + [deviceSetCols.name]: { + defaultDirection: SORT_ASC, + }, + [deviceSetCols.zone]: { + defaultDirection: SORT_ASC, + }, + [deviceSetCols.miners]: { + defaultDirection: SORT_DESC, + }, + [deviceSetCols.issues]: { + defaultDirection: SORT_DESC, + }, +}; + +export const RACK_SORT_OPTIONS: DeviceSetSortOption[] = [ + { id: deviceSetCols.name, label: "Name" }, + { id: deviceSetCols.zone, label: "Zone" }, + { id: deviceSetCols.miners, label: "Miners" }, + { id: deviceSetCols.issues, label: "Issues" }, +]; + +export const SORTABLE_COLUMNS = new Set(Object.keys(SORT_CONFIG) as DeviceSetColumn[]); + +function toggleSortDirection(direction: SortDirection): SortDirection { + return direction === SORT_ASC ? SORT_DESC : SORT_ASC; +} + +function isSortableColumn(value: string): value is DeviceSetColumn { + return SORTABLE_COLUMNS.has(value as DeviceSetColumn); +} + +function getDropdownSortDirection(column: DeviceSetColumn): SortDirection { + return column === deviceSetCols.issues ? SORT_DESC : SORT_ASC; +} + +function getSelectedSortField(selected: string[], currentField: DeviceSetColumn): DeviceSetColumn { + return ( + selected.find((value): value is DeviceSetColumn => isSortableColumn(value) && value !== currentField) ?? + selected.find(isSortableColumn) ?? + currentField + ); +} + +export function getDefaultSortDirection(column: DeviceSetColumn): SortDirection { + return SORT_CONFIG[column]?.defaultDirection ?? SORT_ASC; +} + +export function getNextSortFromSelection(selected: string[], currentSort: DeviceSetSortState): DeviceSetSortState { + if (selected.length === 0) { + return { + field: currentSort.field, + direction: toggleSortDirection(currentSort.direction), + }; + } + + const field = getSelectedSortField(selected, currentSort.field); + const direction = + field === currentSort.field ? toggleSortDirection(currentSort.direction) : getDropdownSortDirection(field); + + return { + field, + direction, + }; +} diff --git a/client/src/protoFleet/components/FirmwareUpload/FirmwareUploadComponents.tsx b/client/src/protoFleet/components/FirmwareUpload/FirmwareUploadComponents.tsx new file mode 100644 index 000000000..f9eed5f26 --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/FirmwareUploadComponents.tsx @@ -0,0 +1,181 @@ +import type { ChangeEvent, DragEvent } from "react"; +import { useCallback, useRef, useState } from "react"; +import clsx from "clsx"; +import { Checkmark } from "@/shared/assets/icons"; +import { formatFileSize } from "@/shared/components/FileSizeValue"; +import ProgressCircular from "@/shared/components/ProgressCircular/ProgressCircular"; + +const MIME_TYPES_BY_EXT: Record = { + ".tar.gz": ["application/gzip", "application/x-gzip", ".gz"], + ".zip": ["application/zip"], +}; + +function buildAcceptString(extensions: string[]): string { + const parts = new Set(); + for (const ext of extensions) { + parts.add(ext); + for (const mime of MIME_TYPES_BY_EXT[ext] ?? []) parts.add(mime); + } + return [...parts].join(","); +} + +interface FileDropZoneProps { + extensions: string[]; + onFileSelect: (file: File) => void; + disabled?: boolean; +} + +export function FileDropZone({ extensions, onFileSelect, disabled }: FileDropZoneProps) { + const [isDragActive, setIsDragActive] = useState(false); + const fileInputRef = useRef(null); + + const handleDragEnter = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(true); + }, []); + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + }, []); + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + if (disabled) return; + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) onFileSelect(droppedFile); + }, + [disabled, onFileSelect], + ); + + const handleClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileInputChange = useCallback( + (e: ChangeEvent) => { + const selected = e.target.files?.[0]; + if (selected) onFileSelect(selected); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, + [onFileSelect], + ); + + const formattedExtensions = + extensions.length <= 1 + ? extensions.join(", ") + : `${extensions.slice(0, -1).join(", ")}, and ${extensions[extensions.length - 1]}`; + + return ( +
+
+
Drag update files here
+
or
+ +
+
Supported file types: {formattedExtensions}
+ +
+ ); +} + +interface FileProcessingStatusProps { + state: "hashing" | "checking" | "uploading"; + fileName: string; + fileSize: number; + uploadProgress: number; +} + +export function FileProcessingStatus({ state, fileName, fileSize, uploadProgress }: FileProcessingStatusProps) { + return ( +
+ {state === "uploading" ? ( + + ) : ( + + )} +
+
{fileName}
+
+ {state === "hashing" && "Computing checksum..."} + {state === "checking" && "Checking server..."} + {state === "uploading" && `${uploadProgress}% uploaded · ${formatFileSize(fileSize)}`} +
+
+
+ ); +} + +interface FileReadyStatusProps { + fileName: string; + fileSize: number; +} + +export function FileReadyStatus({ fileName, fileSize }: FileReadyStatusProps) { + return ( +
+ +
+
{fileName}
+
{formatFileSize(fileSize)} · Ready
+
+
+ ); +} + +interface FileErrorStatusProps { + message: string; + onRetry: () => void; +} + +export function FileErrorStatus({ message, onRetry }: FileErrorStatusProps) { + return ( +
+
{message}
+ +
+ ); +} diff --git a/client/src/protoFleet/components/FirmwareUpload/index.ts b/client/src/protoFleet/components/FirmwareUpload/index.ts new file mode 100644 index 000000000..7ec85d117 --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/index.ts @@ -0,0 +1,3 @@ +export { useFirmwareUpload } from "./useFirmwareUpload"; +export type { UploadState, UseFirmwareUploadReturn } from "./useFirmwareUpload"; +export { FileDropZone, FileProcessingStatus, FileReadyStatus, FileErrorStatus } from "./FirmwareUploadComponents"; diff --git a/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.test.ts b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.test.ts new file mode 100644 index 000000000..edcd8db00 --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.test.ts @@ -0,0 +1,317 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useFirmwareUpload } from "./useFirmwareUpload"; + +const mockGetConfig = vi.fn(); +const mockCheckFirmwareFile = vi.fn(); +const mockUploadFirmwareFile = vi.fn(); + +vi.mock("@/protoFleet/api/useFirmwareApi", () => ({ + useFirmwareApi: () => ({ + getConfig: mockGetConfig, + checkFirmwareFile: mockCheckFirmwareFile, + uploadFirmwareFile: mockUploadFirmwareFile, + }), + computeSha256: vi.fn().mockResolvedValue("abc123sha256"), + validateFirmwareFile: vi.fn().mockReturnValue(null), +})); + +const defaultConfig = { + allowedExtensions: [".swu", ".tar.gz", ".zip"], + maxFileSizeBytes: 500 * 1024 * 1024, + chunkSizeBytes: 32 * 1024 * 1024, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetConfig.mockResolvedValue(defaultConfig); + mockCheckFirmwareFile.mockResolvedValue({ exists: false }); + mockUploadFirmwareFile.mockResolvedValue("fw-new-id"); +}); + +describe("useFirmwareUpload", () => { + describe("initial state", () => { + it("returns idle state when inactive", () => { + const { result } = renderHook(() => useFirmwareUpload(false)); + + expect(result.current.state).toBe("idle"); + expect(result.current.file).toBeNull(); + expect(result.current.firmwareFileId).toBeNull(); + expect(result.current.uploadProgress).toBe(0); + expect(result.current.errorMessage).toBeNull(); + expect(result.current.serverConfig).toBeNull(); + }); + + it("does not fetch config when inactive", () => { + renderHook(() => useFirmwareUpload(false)); + + expect(mockGetConfig).not.toHaveBeenCalled(); + }); + }); + + describe("config loading", () => { + it("fetches config when active", async () => { + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).toEqual(defaultConfig); + }); + expect(result.current.state).toBe("idle"); + }); + + it("sets error state when config fetch fails", async () => { + mockGetConfig.mockRejectedValue(new Error("Network error")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Network error"); + expect(result.current.serverConfig).toBeNull(); + }); + + it("retry re-fetches config after failure", async () => { + mockGetConfig.mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + + mockGetConfig.mockResolvedValue(defaultConfig); + + act(() => { + result.current.retry(); + }); + + await vi.waitFor(() => { + expect(result.current.serverConfig).toEqual(defaultConfig); + }); + expect(result.current.state).toBe("idle"); + expect(result.current.errorMessage).toBeNull(); + expect(mockGetConfig).toHaveBeenCalledTimes(2); + }); + }); + + describe("processFile", () => { + it("completes upload when file does not exist on server", async () => { + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + const file = new File(["data"], "firmware.swu"); + + act(() => { + result.current.processFile(file); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + expect(result.current.firmwareFileId).toBe("fw-new-id"); + expect(result.current.file).toBe(file); + expect(mockUploadFirmwareFile).toHaveBeenCalled(); + }); + + it("skips upload when file already exists on server (SHA-256 dedup)", async () => { + mockCheckFirmwareFile.mockResolvedValue({ exists: true, firmwareFileId: "fw-existing" }); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + const file = new File(["data"], "firmware.swu"); + + act(() => { + result.current.processFile(file); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + expect(result.current.firmwareFileId).toBe("fw-existing"); + expect(mockUploadFirmwareFile).not.toHaveBeenCalled(); + }); + + it("falls back to getConfig when called before config loads", async () => { + mockGetConfig.mockReturnValueOnce(new Promise(() => {})); + mockGetConfig.mockResolvedValueOnce(defaultConfig); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + expect(result.current.serverConfig).toBeNull(); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + expect(result.current.firmwareFileId).toBe("fw-new-id"); + expect(mockGetConfig).toHaveBeenCalledTimes(2); + }); + + it("sets error state when check fails", async () => { + mockCheckFirmwareFile.mockRejectedValue(new Error("Check failed")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Check failed"); + expect(mockUploadFirmwareFile).not.toHaveBeenCalled(); + }); + + it("aborts previous upload when processFile is called again", async () => { + let resolveFirstUpload: (value: string) => void; + mockUploadFirmwareFile + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirstUpload = resolve))) + .mockResolvedValueOnce("fw-second-id"); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "first.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("uploading"); + }); + + act(() => { + result.current.processFile(new File(["data"], "second.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("ready"); + }); + + act(() => { + resolveFirstUpload!("fw-first-id"); + }); + + expect(result.current.firmwareFileId).toBe("fw-second-id"); + expect(result.current.file?.name).toBe("second.swu"); + }); + + it("sets error state when upload fails", async () => { + mockUploadFirmwareFile.mockRejectedValue(new Error("Upload failed")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Upload failed"); + }); + + it("sets error state on validation failure", async () => { + const { validateFirmwareFile } = await import("@/protoFleet/api/useFirmwareApi"); + vi.mocked(validateFirmwareFile).mockReturnValueOnce("Unsupported file type"); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.bin")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + expect(result.current.errorMessage).toBe("Unsupported file type"); + }); + }); + + describe("reset", () => { + it("clears all upload state back to idle", async () => { + mockUploadFirmwareFile.mockRejectedValue(new Error("fail")); + + const { result } = renderHook(() => useFirmwareUpload(true)); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("error"); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.state).toBe("idle"); + expect(result.current.file).toBeNull(); + expect(result.current.firmwareFileId).toBeNull(); + expect(result.current.errorMessage).toBeNull(); + expect(result.current.uploadProgress).toBe(0); + }); + }); + + describe("cleanup", () => { + it("aborts in-flight operations when active flips to false", async () => { + let resolveUpload: (value: string) => void; + mockUploadFirmwareFile.mockImplementation(() => new Promise((resolve) => (resolveUpload = resolve))); + + const { result, rerender } = renderHook(({ active }) => useFirmwareUpload(active), { + initialProps: { active: true }, + }); + + await vi.waitFor(() => { + expect(result.current.serverConfig).not.toBeNull(); + }); + + act(() => { + result.current.processFile(new File(["data"], "firmware.swu")); + }); + + await vi.waitFor(() => { + expect(result.current.state).toBe("uploading"); + }); + + rerender({ active: false }); + + act(() => { + resolveUpload!("fw-id"); + }); + + expect(result.current.state).not.toBe("ready"); + }); + }); +}); diff --git a/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.ts b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.ts new file mode 100644 index 000000000..5a33c3b0a --- /dev/null +++ b/client/src/protoFleet/components/FirmwareUpload/useFirmwareUpload.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { FirmwareConfig } from "@/protoFleet/api/useFirmwareApi"; +import { computeSha256, useFirmwareApi, validateFirmwareFile } from "@/protoFleet/api/useFirmwareApi"; + +export type UploadState = "idle" | "hashing" | "checking" | "uploading" | "ready" | "error"; + +export interface UseFirmwareUploadReturn { + state: UploadState; + file: File | null; + firmwareFileId: string | null; + uploadProgress: number; + errorMessage: string | null; + serverConfig: FirmwareConfig | null; + processFile: (file: File) => void; + reset: () => void; + retry: () => void; +} + +export function useFirmwareUpload(active: boolean): UseFirmwareUploadReturn { + const [state, setState] = useState("idle"); + const [file, setFile] = useState(null); + const [firmwareFileId, setFirmwareFileId] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const [serverConfig, setServerConfig] = useState(null); + const [retryCount, setRetryCount] = useState(0); + const abortControllerRef = useRef(null); + + const { getConfig, checkFirmwareFile, uploadFirmwareFile } = useFirmwareApi(); + + useEffect(() => { + if (active) { + let cancelled = false; + void getConfig() + .then((config) => { + if (cancelled) return; + setServerConfig(config); + setState((prev) => (prev === "error" ? "idle" : prev)); + setErrorMessage(null); + }) + .catch((err) => { + if (cancelled) return; + setErrorMessage(err instanceof Error ? err.message : "Failed to load firmware configuration."); + setState("error"); + }); + return () => { + cancelled = true; + }; + } + }, [active, getConfig, retryCount]); + + useEffect(() => { + if (!active) { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + } + return () => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + }; + }, [active]); + + const reset = useCallback(() => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setState("idle"); + setFile(null); + setFirmwareFileId(null); + setUploadProgress(0); + setErrorMessage(null); + }, []); + + const retry = useCallback(() => { + reset(); + setRetryCount((c) => c + 1); + }, [reset]); + + const processFile = useCallback( + async (selectedFile: File) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + const config = serverConfig ?? (await getConfig()); + if (controller.signal.aborted) return; + + const validationError = validateFirmwareFile(selectedFile, config); + if (validationError) { + setErrorMessage(validationError); + setState("error"); + return; + } + + setFile(selectedFile); + setState("hashing"); + const sha256 = await computeSha256(selectedFile); + if (controller.signal.aborted) return; + + setState("checking"); + const { exists, firmwareFileId: existingId } = await checkFirmwareFile(sha256, controller.signal); + if (controller.signal.aborted) return; + + if (exists && existingId) { + setFirmwareFileId(existingId); + setState("ready"); + return; + } + + setState("uploading"); + setUploadProgress(0); + const newId = await uploadFirmwareFile(selectedFile, { + onProgress: setUploadProgress, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + setFirmwareFileId(newId); + setState("ready"); + } catch (err) { + if (controller.signal.aborted) return; + setErrorMessage(err instanceof Error ? err.message : String(err)); + setState("error"); + } + }, + [checkFirmwareFile, uploadFirmwareFile, serverConfig, getConfig], + ); + + const wrappedProcessFile = useCallback((f: File) => void processFile(f), [processFile]); + + return { + state, + file, + firmwareFileId, + uploadProgress, + errorMessage, + serverConfig, + processFile: wrappedProcessFile, + reset, + retry, + }; +} diff --git a/client/src/protoFleet/components/Footer/Footer.tsx b/client/src/protoFleet/components/Footer/Footer.tsx new file mode 100644 index 000000000..7f26dac73 --- /dev/null +++ b/client/src/protoFleet/components/Footer/Footer.tsx @@ -0,0 +1,15 @@ +import BuildVersionInfo from "@/shared/components/BuildVersionInfo"; + +/** + * Footer component for the ProtoFleet application + * Includes version information and potentially other footer content + */ +const Footer = () => { + return ( +
+ +
+ ); +}; + +export default Footer; diff --git a/client/src/protoFleet/components/Footer/index.ts b/client/src/protoFleet/components/Footer/index.ts new file mode 100644 index 000000000..3738288b0 --- /dev/null +++ b/client/src/protoFleet/components/Footer/index.ts @@ -0,0 +1 @@ +export { default } from "./Footer"; diff --git a/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.stories.tsx b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.stories.tsx new file mode 100644 index 000000000..aac952f10 --- /dev/null +++ b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.stories.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import FullScreenTwoPaneModal from "./FullScreenTwoPaneModal"; +import { DismissCircle } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import ProgressCircular from "@/shared/components/ProgressCircular"; + +const SamplePane = ({ label, className }: { label: string; className?: string }) => ( +
+
{label}
+
+
Sample content for {label.toLowerCase()}
+
+
+
Additional content block
+
+
+); + +const PreviewPane = () => ( +
+
Preview
+
+
Preview content appears here
+
+
+); + +type StoryArgs = { + title: string; + isBusy?: boolean; + hasButtons?: boolean; + maxWidth?: string; + showAbovePanes?: boolean; + showLoadingState?: boolean; +}; + +const FullScreenTwoPaneModalStory = ({ + title, + isBusy, + hasButtons = true, + maxWidth, + showAbovePanes, + showLoadingState, +}: StoryArgs) => { + const [open, setOpen] = useState(true); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( + setOpen(false)} + isBusy={isBusy} + buttons={ + hasButtons + ? [ + { text: "Secondary", variant: variants.secondary, onClick: () => {} }, + { text: "Save", variant: variants.primary, onClick: () => {}, disabled: isBusy }, + ] + : undefined + } + maxWidth={maxWidth} + abovePanes={ + showAbovePanes ? ( +
+ } + title="Something went wrong. Please try again." + dismissible + /> +
+ ) : undefined + } + loadingState={ + showLoadingState ? ( +
+ +
+ ) : undefined + } + primaryPane={} + secondaryPane={} + /> + ); +}; + +const meta = { + title: "Shared/FullScreenTwoPaneModal", + component: FullScreenTwoPaneModalStory, + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: "Full Screen Two Pane Modal", + }, +}; + +const WithOverflowButtonsRender = (args: StoryArgs) => { + const [open, setOpen] = useState(true); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( + setOpen(false)} + buttons={[ + { text: "Delete", variant: variants.secondaryDanger, onClick: () => {} }, + { text: "Edit Settings", variant: variants.secondary, onClick: () => {} }, + { text: "Manage", variant: variants.secondary, onClick: () => {} }, + { text: "Save", variant: variants.primary, onClick: () => {} }, + ]} + primaryPane={} + secondaryPane={} + /> + ); +}; + +export const WithOverflowButtons: Story = { + args: { + title: "Modal with Overflow Menu", + hasButtons: false, + }, + render: WithOverflowButtonsRender, +}; + +export const BusyState: Story = { + args: { + title: "Saving Changes", + isBusy: true, + hasButtons: true, + }, +}; + +export const WithAbovePanesContent: Story = { + args: { + title: "Modal with Error", + showAbovePanes: true, + }, +}; + +export const WithLoadingState: Story = { + args: { + title: "Loading Data", + showLoadingState: true, + }, +}; + +export const WithMaxWidth: Story = { + args: { + title: "Constrained Width Modal", + maxWidth: "1280px", + }, +}; diff --git a/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.tsx b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.tsx new file mode 100644 index 000000000..e496107a0 --- /dev/null +++ b/client/src/protoFleet/components/FullScreenTwoPaneModal/FullScreenTwoPaneModal.tsx @@ -0,0 +1,220 @@ +import { type ReactNode, useCallback, useRef, useState } from "react"; +import clsx from "clsx"; + +import { Dismiss, Ellipsis } from "@/shared/assets/icons"; +import { sizes, variants } from "@/shared/components/Button"; +import ButtonGroup, { type ButtonProps, groupVariants } from "@/shared/components/ButtonGroup"; +import Divider from "@/shared/components/Divider"; +import Header from "@/shared/components/Header"; +import PageOverlay from "@/shared/components/PageOverlay"; +import Row from "@/shared/components/Row"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; +import { useKeyDown } from "@/shared/hooks/useKeyDown"; + +const defaultPaneContainerClassName = + "flex min-h-[calc(100dvh-200px)] w-full flex-1 flex-col laptop:grid laptop:min-h-0 laptop:grid-cols-2 laptop:px-10 desktop:px-10 desktop:grid desktop:min-h-0 desktop:grid-cols-2"; +const defaultPrimaryPaneClassName = + "order-2 flex flex-col phone:pl-6 tablet:pl-6 laptop:order-1 laptop:min-h-0 laptop:overflow-y-auto laptop:pl-1 desktop:order-1 desktop:min-h-0 desktop:overflow-y-auto desktop:pl-1"; +const defaultSecondaryPaneClassName = + "order-1 flex flex-col self-stretch bg-surface-overlay phone:mb-6 phone:max-h-[50vh] phone:overflow-y-auto tablet:mb-6 tablet:max-h-[50vh] tablet:overflow-y-auto laptop:order-2 laptop:min-h-0 laptop:rounded-xl laptop:pl-6 desktop:order-2 desktop:min-h-0 desktop:rounded-xl desktop:pl-6"; + +interface FullScreenTwoPaneModalProps { + open: boolean; + title: string; + onDismiss?: () => void; + isBusy?: boolean; + closeAriaLabel?: string; + buttons?: ButtonProps[]; + primaryPane: ReactNode; + secondaryPane: ReactNode; + abovePanes?: ReactNode; + loadingState?: ReactNode; + maxWidth?: string; + paneContainerClassName?: string; + primaryPaneClassName?: string; + secondaryPaneClassName?: string; + className?: string; + zIndex?: string; +} + +const isDangerVariant = (variant: string) => variant === variants.danger || variant === variants.secondaryDanger; + +const OverflowActionSheet = ({ overflowButtons, onClose }: { overflowButtons: ButtonProps[]; onClose: () => void }) => { + const sheetRef = useRef(null); + useClickOutside({ ref: sheetRef, onClickOutside: onClose }); + useKeyDown({ + key: "Escape", + onKeyDown: () => onClose(), + }); + + const nonDangerItems = overflowButtons.filter((b) => !isDangerVariant(b.variant)); + const dangerItems = overflowButtons.filter((b) => isDangerVariant(b.variant)); + + return ( +
+
+ {nonDangerItems.map((button, index) => ( + { + button.onClick?.(); + onClose(); + } + } + divider={false} + > + {button.text} + + ))} + + {dangerItems.length > 0 && nonDangerItems.length > 0 && } + + {dangerItems.map((button, index) => ( + { + button.onClick?.(); + onClose(); + } + } + divider={false} + > + {button.text} + + ))} +
+
+ ); +}; + +const FullScreenTwoPaneModal = ({ + open, + title, + onDismiss, + isBusy = false, + closeAriaLabel = "Close dialog", + buttons, + primaryPane, + secondaryPane, + abovePanes, + loadingState, + maxWidth = "none", + paneContainerClassName, + primaryPaneClassName, + secondaryPaneClassName, + className, + zIndex, +}: FullScreenTwoPaneModalProps) => { + const [showOverflowSheet, setShowOverflowSheet] = useState(false); + + // Split buttons: primary CTA (last primary-variant button) vs overflow (rest) + let primaryButton: ButtonProps | undefined; + let overflowButtons: ButtonProps[] = []; + + if (buttons && buttons.length > 0) { + if (buttons.length === 1) { + primaryButton = buttons[0]; + } else { + let primaryIndex = -1; + for (let i = buttons.length - 1; i >= 0; i--) { + if (buttons[i].variant === variants.primary) { + primaryIndex = i; + break; + } + } + + if (primaryIndex === -1) { + primaryButton = buttons[buttons.length - 1]; + overflowButtons = buttons.slice(0, -1); + } else { + primaryButton = buttons[primaryIndex]; + overflowButtons = buttons.filter((_, i) => i !== primaryIndex); + } + } + } + + const closeSheet = useCallback(() => setShowOverflowSheet(false), []); + + const mobileButtons: ButtonProps[] = []; + + if (overflowButtons.length > 0) { + mobileButtons.push({ + variant: variants.secondary, + onClick: () => setShowOverflowSheet(true), + prefixIcon: , + testId: "overflow-menu-trigger", + ariaLabel: "More actions", + }); + } + + if (primaryButton) { + mobileButtons.push(primaryButton); + } + + return ( + +
+
+
+
} + iconOnClick={() => { + if (!isBusy) { + onDismiss?.(); + } + }} + iconTextColor={isBusy ? "text-text-primary-30" : "text-text-primary"} + inline + buttonsWrapperClassName="hidden laptop:block desktop:block" + buttons={buttons} + > + {/* Mobile buttons: ellipsis + primary CTA */} +
+ +
+
+
+ + {abovePanes} + + {loadingState ?? ( +
+
+
{primaryPane}
+
{secondaryPane}
+
+
+ )} +
+
+ + {showOverflowSheet && } +
+ ); +}; + +export default FullScreenTwoPaneModal; +export type { FullScreenTwoPaneModalProps }; diff --git a/client/src/protoFleet/components/FullScreenTwoPaneModal/index.ts b/client/src/protoFleet/components/FullScreenTwoPaneModal/index.ts new file mode 100644 index 000000000..74504426f --- /dev/null +++ b/client/src/protoFleet/components/FullScreenTwoPaneModal/index.ts @@ -0,0 +1,5 @@ +import FullScreenTwoPaneModal from "./FullScreenTwoPaneModal"; +import type { FullScreenTwoPaneModalProps } from "./FullScreenTwoPaneModal"; + +export default FullScreenTwoPaneModal; +export type { FullScreenTwoPaneModalProps }; diff --git a/client/src/protoFleet/components/LineChart/LineChart.tsx b/client/src/protoFleet/components/LineChart/LineChart.tsx new file mode 100644 index 000000000..ac1353833 --- /dev/null +++ b/client/src/protoFleet/components/LineChart/LineChart.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; +import SharedLineChart, { type LineChartProps as SharedLineChartProps } from "@/shared/components/LineChart"; + +export type LineChartProps = SharedLineChartProps & { + heightClass?: string; + duration?: FleetDuration; +}; + +const LineChart = ({ heightClass = "h-100", duration, ...props }: LineChartProps) => { + const { chartData } = props; + + const xAxisDomainOverride = useMemo((): [number, number] | undefined => { + if (!duration || !chartData?.length) return undefined; + const durationMs = getFleetDurationMs(duration); + const startTime = chartData[0].datetime; + return [startTime, startTime + durationMs]; + }, [duration, chartData]); + + const fleetProps = { + ...props, + xAxisDomainOverride, + connectNulls: true, + yAxisTickYOffset: -8, // Move labels up to position above grid lines + visibleTickIndices: [0, 2, 4], // Show labels on lines 1, 3, and 5 + chartMarginTop: 20, // Add top margin to prevent label cutoff + xAxisLabelCount: 4, // Show 4 timestamp positions (last one will be empty) + }; + + return ( +
+ +
+ ); +}; + +export default LineChart; diff --git a/client/src/protoFleet/components/LineChart/index.ts b/client/src/protoFleet/components/LineChart/index.ts new file mode 100644 index 000000000..52a16111a --- /dev/null +++ b/client/src/protoFleet/components/LineChart/index.ts @@ -0,0 +1 @@ +export { default } from "./LineChart"; diff --git a/client/src/protoFleet/components/MinerSelectionList.tsx b/client/src/protoFleet/components/MinerSelectionList.tsx new file mode 100644 index 000000000..04ea3d685 --- /dev/null +++ b/client/src/protoFleet/components/MinerSelectionList.tsx @@ -0,0 +1,436 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { + SortConfigSchema, + SortDirection as SortDirectionProto, + SortField, +} from "@/protoFleet/api/generated/common/v1/sort_pb"; +import type { DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import type { MinerStateSnapshot as ProtoMinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { + type MinerListFilter, + MinerListFilterSchema, + PairingStatus, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import useFleet from "@/protoFleet/api/useFleet"; +import { INACTIVE_PLACEHOLDER } from "@/protoFleet/features/fleetManagement/components/MinerList/constants"; + +import { ChevronDown } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import List from "@/shared/components/List"; +import type { ActiveFilters, FilterItem } from "@/shared/components/List/Filters/types"; +import type { ColConfig, ColTitles, SortDirection } from "@/shared/components/List/types"; +import { ModalSelectAllFooter } from "@/shared/components/Modal"; +import ProgressCircular from "@/shared/components/ProgressCircular"; + +// --- Exported types --- + +export type DeviceListItem = { + deviceIdentifier: string; + name: string; + model: string; + ipAddress: string; + rackLabel: string; + groupLabels: string[]; +}; + +export type FilterConfig = { + showTypeFilter?: boolean; + showRackFilter?: boolean; + showGroupFilter?: boolean; +}; + +export interface MinerSelectionListHandle { + getSelection: () => { + selectedItems: string[]; + allSelected: boolean; + totalMiners: number | undefined; + filter: MinerListFilter; + }; +} + +export interface MinerSelectionListProps { + filterConfig?: FilterConfig; + initialSelectedItems?: string[]; + isMembersLoading?: boolean; + isRowDisabled?: (item: DeviceListItem) => boolean; + /** When true, renders radio buttons for single-item selection instead of checkboxes. */ + singleSelect?: boolean; + showSelectAllFooter?: boolean; + onSelectionChange?: (state: { + selectedItems: string[]; + allSelected: boolean; + totalMiners: number | undefined; + }) => void; +} + +// --- Constants --- + +const modalCols = { + name: "name", + type: "type", + rack: "rack", + ipAddress: "ipAddress", + group: "group", +} as const; + +type ModalColumn = (typeof modalCols)[keyof typeof modalCols]; + +const modalColTitles: ColTitles = { + name: "Name", + type: "Model", + rack: "Rack", + ipAddress: "IP address", + group: "Group", +}; + +const activeCols: ModalColumn[] = [ + modalCols.name, + modalCols.type, + modalCols.rack, + modalCols.ipAddress, + modalCols.group, +]; + +const modalColConfig: ColConfig = { + [modalCols.name]: { + component: (device: DeviceListItem) => {device.name || device.deviceIdentifier}, + width: "min-w-28", + }, + [modalCols.type]: { + component: (device: DeviceListItem) => {device.model || INACTIVE_PLACEHOLDER}, + width: "min-w-20", + }, + [modalCols.rack]: { + component: (device: DeviceListItem) => {device.rackLabel || INACTIVE_PLACEHOLDER}, + width: "min-w-28", + }, + [modalCols.ipAddress]: { + component: (device: DeviceListItem) => {device.ipAddress || INACTIVE_PLACEHOLDER}, + width: "min-w-24", + }, + [modalCols.group]: { + component: (device: DeviceListItem) => { + const label = device.groupLabels.length > 0 ? device.groupLabels.join(", ") : INACTIVE_PLACEHOLDER; + return {label}; + }, + width: "min-w-24 max-w-48", + }, +}; + +/** Columns that support server-side sorting, mapped to their proto SortField. */ +const SORT_FIELD_BY_COLUMN: Partial> = { + [modalCols.name]: SortField.NAME, + [modalCols.type]: SortField.MODEL, + [modalCols.ipAddress]: SortField.IP_ADDRESS, +}; + +const ALL_SORTABLE_COLUMNS = new Set(Object.keys(SORT_FIELD_BY_COLUMN) as ModalColumn[]); + +const PAGE_SIZE = 50; + +const toDeviceListItem = (miner: ProtoMinerStateSnapshot): DeviceListItem => ({ + deviceIdentifier: miner.deviceIdentifier, + name: miner.name, + model: miner.model, + ipAddress: miner.ipAddress, + rackLabel: miner.rackLabel, + groupLabels: miner.groupLabels, +}); + +// --- Component --- + +const MinerSelectionList = forwardRef( + ( + { + filterConfig, + initialSelectedItems, + isMembersLoading = false, + isRowDisabled, + singleSelect = false, + showSelectAllFooter = true, + onSelectionChange, + }, + ref, + ) => { + const { showTypeFilter = true, showRackFilter = true, showGroupFilter = true } = filterConfig ?? {}; + + const { listGroups, listRacks } = useDeviceSets(); + const [filter, setFilter] = useState(() => create(MinerListFilterSchema, {})); + const [selectedItems, setSelectedItems] = useState(initialSelectedItems ?? []); + const [allSelected, setAllSelected] = useState(false); + const [availableGroups, setAvailableGroups] = useState([]); + const [availableRacks, setAvailableRacks] = useState([]); + const [hasInitialSynced, setHasInitialSynced] = useState(!initialSelectedItems || initialSelectedItems.length > 0); + const [currentSort, setCurrentSort] = useState<{ field: ModalColumn; direction: SortDirection } | undefined>( + undefined, + ); + + // Build proto SortConfig from the current UI sort state + const sortConfig = useMemo(() => { + if (!currentSort) return undefined; + const protoField = SORT_FIELD_BY_COLUMN[currentSort.field]; + if (!protoField) return undefined; + return create(SortConfigSchema, { + field: protoField, + direction: currentSort.direction === "asc" ? SortDirectionProto.ASC : SortDirectionProto.DESC, + }); + }, [currentSort]); + + const { + minerIds, + miners, + totalMiners, + isLoading, + hasMore, + currentPage, + hasPreviousPage, + goToNextPage, + goToPrevPage, + availableModels, + } = useFleet({ + filter, + sort: sortConfig, + pageSize: PAGE_SIZE, + pairingStatuses: [PairingStatus.PAIRED], + }); + + const currentPageItems = useMemo(() => { + if (!miners) return []; + return minerIds + .map((id) => miners[id]) + .filter((snapshot): snapshot is ProtoMinerStateSnapshot => Boolean(snapshot)) + .map(toDeviceListItem); + }, [minerIds, miners]); + + const handleSort = useCallback((field: ModalColumn, direction: SortDirection) => { + setCurrentSort({ field, direction }); + }, []); + + const scrollRef = useRef(null); + const currentPageItemsRef = useRef(currentPageItems); + useEffect(() => { + currentPageItemsRef.current = currentPageItems; + }, [currentPageItems]); + + const scrollToTop = useCallback(() => { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + + // Sync initialSelectedItems when they arrive asynchronously (edit mode). + // Uses queueMicrotask to avoid synchronous setState inside effect body. + useEffect(() => { + if (hasInitialSynced) return; + if (initialSelectedItems && initialSelectedItems.length > 0) { + queueMicrotask(() => { + setSelectedItems(initialSelectedItems); + setHasInitialSynced(true); + }); + } + }, [initialSelectedItems, hasInitialSynced]); + + // Notify parent of selection changes + useEffect(() => { + onSelectionChange?.({ selectedItems, allSelected, totalMiners }); + }, [selectedItems, allSelected, totalMiners, onSelectionChange]); + + // Expose selection state to parent via imperative handle + useImperativeHandle( + ref, + () => ({ + getSelection: () => ({ selectedItems, allSelected, totalMiners, filter }), + }), + [selectedItems, allSelected, totalMiners, filter], + ); + + const handleSetSelectedItems = useCallback( + (newSelection: string[]) => { + setAllSelected(false); + if (singleSelect) { + // In single-select mode, just keep the selected item (no off-page merging) + setSelectedItems(newSelection.slice(0, 1)); + } else { + setSelectedItems((prev) => { + const currentPageKeys = new Set(currentPageItemsRef.current.map((d) => d.deviceIdentifier)); + const offPageSelections = prev.filter((id) => !currentPageKeys.has(id)); + return [...offPageSelections, ...newSelection.filter((id) => currentPageKeys.has(id))]; + }); + } + }, + [singleSelect], + ); + + const handleNextPage = useCallback(() => { + scrollToTop(); + goToNextPage(); + }, [scrollToTop, goToNextPage]); + + const handlePrevPage = useCallback(() => { + scrollToTop(); + goToPrevPage(); + }, [scrollToTop, goToPrevPage]); + + // Fetch filter options only for enabled filters + useEffect(() => { + if (showGroupFilter) listGroups({ onSuccess: setAvailableGroups }); + if (showRackFilter) listRacks({ onSuccess: setAvailableRacks }); + }, [showGroupFilter, showRackFilter, listGroups, listRacks]); + + const filters = useMemo((): FilterItem[] => { + const items: FilterItem[] = []; + if (showTypeFilter) { + items.push({ + type: "dropdown", + title: "Model", + value: "type", + options: availableModels.map((model) => ({ id: model, label: model })), + defaultOptionIds: [], + }); + } + if (showRackFilter) { + items.push({ + type: "dropdown", + title: "Rack", + value: "rack", + options: availableRacks.map((rack) => ({ id: String(rack.id), label: rack.label })), + defaultOptionIds: [], + }); + } + if (showGroupFilter) { + items.push({ + type: "dropdown", + title: "Group", + value: "group", + options: availableGroups.map((g) => ({ id: String(g.id), label: g.label })), + defaultOptionIds: [], + }); + } + return items; + }, [showTypeFilter, showRackFilter, showGroupFilter, availableModels, availableRacks, availableGroups]); + + const handleServerFilter = useCallback( + async (activeFilters: ActiveFilters) => { + const minerFilter = create(MinerListFilterSchema, { + errorComponentTypes: [], + }); + + const typeFilters = activeFilters.dropdownFilters.type; + if (typeFilters && typeFilters.length > 0) { + minerFilter.models.push(...typeFilters); + } + + if (showRackFilter) { + const rackFilters = activeFilters.dropdownFilters.rack; + if (rackFilters && rackFilters.length > 0) { + minerFilter.rackIds.push(...rackFilters.map((id) => BigInt(id))); + } + } + + if (showGroupFilter) { + const groupFilters = activeFilters.dropdownFilters.group; + if (groupFilters && groupFilters.length > 0) { + minerFilter.groupIds.push(...groupFilters.map((id) => BigInt(id))); + } + } + + setFilter(minerFilter); + }, + [showRackFilter, showGroupFilter], + ); + + const showSpinner = (isLoading || isMembersLoading) && currentPageItems.length === 0; + + if (showSpinner) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + activeCols={activeCols} + colTitles={modalColTitles} + colConfig={modalColConfig} + filters={filters} + onServerFilter={handleServerFilter} + items={currentPageItems} + itemKey="deviceIdentifier" + itemSelectable + selectionType={singleSelect ? "radio" : "checkbox"} + sortableColumns={ALL_SORTABLE_COLUMNS} + currentSort={currentSort} + onSort={handleSort} + customSelectedItems={selectedItems} + customSetSelectedItems={handleSetSelectedItems} + preserveOffPageSelection + isRowDisabled={isRowDisabled} + total={totalMiners} + hideTotal + itemName={{ singular: "miner", plural: "miners" }} + containerClassName="min-h-0" + overflowContainer + stickyBgColor="bg-surface-elevated-base" + footerContent={ + !isLoading && + totalMiners !== undefined && + totalMiners > 0 && ( +
+ + Showing {currentPage * PAGE_SIZE + 1}–{currentPage * PAGE_SIZE + currentPageItems.length} of{" "} + {totalMiners} miners + +
+
+
+ ) + } + /> +
+ {showSelectAllFooter && totalMiners !== undefined && !singleSelect && ( +
+ { + setAllSelected(true); + const selectableItems = isRowDisabled + ? currentPageItems.filter((d) => !isRowDisabled(d)) + : currentPageItems; + setSelectedItems(selectableItems.map((d) => d.deviceIdentifier)); + }} + onSelectNone={() => { + setAllSelected(false); + setSelectedItems([]); + }} + /> +
+ )} +
+ ); + }, +); + +MinerSelectionList.displayName = "MinerSelectionList"; + +export default MinerSelectionList; diff --git a/client/src/protoFleet/components/MiningPools/MiningPoolsForm.stories.tsx b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.stories.tsx new file mode 100644 index 000000000..9a36b8a41 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.stories.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { action } from "storybook/actions"; +import MiningPoolsFormComponent from "@/protoFleet/components/MiningPools/MiningPoolsForm"; +import { MockedPoolApis } from "@/protoFleet/stories/MockedPoolApis"; + +const withMockedPoolApis = (Story: () => ReactNode) => ( + + + +); + +interface MiningPoolsFormArgs { + buttonLabel: string; +} + +export const MiningPoolsForm = ({ buttonLabel }: MiningPoolsFormArgs) => { + return ( + {}} + /> + ); +}; + +export default { + title: "Proto Fleet/MiningPoolsForm", + decorators: [withMockedPoolApis], + args: { + buttonLabel: "Continue", + }, +}; diff --git a/client/src/protoFleet/components/MiningPools/MiningPoolsForm.test.tsx b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.test.tsx new file mode 100644 index 000000000..88b09af72 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.test.tsx @@ -0,0 +1,93 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import MiningPoolsForm from "."; + +const mocks = vi.hoisted(() => { + return { + pools: [] as { url: string; username: string }[], + }; +}); + +vi.mock("@/protoFleet/api/usePools", () => ({ + default: () => ({ + pools: mocks.pools, + createPool: vi.fn().mockResolvedValue({}), + updatePool: vi.fn().mockResolvedValue({}), + deletePool: vi.fn().mockResolvedValue({}), + }), +})); + +vi.mock("@/protoFleet/api/useOnboardedStatus", () => ({ + useOnboardedStatus: () => ({ + poolConfigured: false, + devicePaired: false, + statusLoaded: true, + refetch: vi.fn(), + }), +})); + +describe("MiningPoolsForm", () => { + const buttonLabel = "Continue"; + + test("renders default and backup pool rows", () => { + const { getByText, getAllByText } = render(); + + expect(getByText("Default pool")).toBeInTheDocument(); + expect(getByText("Backup pool #1")).toBeInTheDocument(); + expect(getByText("Backup pool #2")).toBeInTheDocument(); + expect(getAllByText("Not configured")).toHaveLength(3); + }); + + test("displays warning when default pool is invalid", () => { + const { getByTestId, getByRole } = render(); + + const saveButton = getByRole("button", { name: buttonLabel }); + fireEvent.click(saveButton); + expect(getByTestId("warn-default-pool-callout")).toBeInTheDocument(); + }); + + test("disables save button while loading", async () => { + // ensure we have a valid default pool + mocks.pools = [{ url: "https://example.com", username: "user" }]; + + const { getByRole } = render(); + + await waitFor(() => { + // When pools are initialized, at least one "Not configured" should change + const notConfiguredCount = document.body.textContent?.match(/Not configured/g)?.length || 0; + expect(notConfiguredCount).toBeLessThan(3); + }); + + const saveButton = getByRole("button", { name: buttonLabel }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(saveButton).toBeDisabled(); + }); + }); + + test("calls onSaveDone after successful save", async () => { + const mockOnSaveRequested = vi.fn(); + const mockOnSaveDone = vi.fn(); + + // Ensure we have a valid default pool + mocks.pools = [{ url: "https://example.com", username: "user" }]; + + const { getByRole } = render( + , + ); + + await waitFor(() => { + const notConfiguredCount = document.body.textContent?.match(/Not configured/g)?.length || 0; + expect(notConfiguredCount).toBeLessThan(3); + }); + + const saveButton = getByRole("button", { name: buttonLabel }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockOnSaveRequested).toHaveBeenCalled(); + expect(mockOnSaveDone).toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/protoFleet/components/MiningPools/MiningPoolsForm.tsx b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.tsx new file mode 100644 index 000000000..f183b0075 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/MiningPoolsForm.tsx @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { + CreatePoolRequestSchema, + DeletePoolRequestSchema, + UpdatePoolRequestSchema, +} from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import { Pool } from "@/protoFleet/api/generated/pools/v1/pools_pb"; + +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; +import usePools from "@/protoFleet/api/usePools"; +import Button from "@/shared/components/Button"; +import Header from "@/shared/components/Header"; +import PoolModal from "@/shared/components/MiningPools/PoolModal"; +import PoolRow from "@/shared/components/MiningPools/PoolRow"; +import { BackupPoolIndex, PoolIndex, PoolInfo as SharedPoolInfo } from "@/shared/components/MiningPools/types"; +import { getEmptyPoolsInfo, isValidPool } from "@/shared/components/MiningPools/utility"; +import { WarnDefaultPoolCallout } from "@/shared/components/MiningPools/WarnDefaultPoolCallout"; + +type PoolInfo = SharedPoolInfo & { + poolId?: bigint; +}; + +interface MiningPoolsProps { + buttonLabel: string; + onSaveRequested?: () => void; + onSaveDone: () => void; + onSaveFailed?: () => void; +} + +const MiningPoolsForm = ({ buttonLabel, onSaveRequested, onSaveDone, onSaveFailed }: MiningPoolsProps) => { + const { pools: existingPools, createPool, updatePool, deletePool, validatePool, validatePoolPending } = usePools(); + + const { refetch: refetchOnboardingStatus } = useOnboardedStatus(); + + const [loading, setLoading] = useState(false); + const [pools, setPools] = useState(getEmptyPoolsInfo()); + + // Initialize and sync pools from existingPools + useEffect(() => { + if (existingPools.length === 0) { + return; + } + const currentPools = existingPools + .sort((a, b) => Number(a.poolId) - Number(b.poolId)) + .map((pool: Pool) => ({ + ...pool, + name: pool.poolName, + password: "", + // TODO: fix priority assignment + priority: pool.poolId, + })); + const maxExistingPriority = Math.max( + // TODO: fix priority assignment + ...existingPools.map((pool: Pool) => Number(pool.poolId)), + ); + const emptyPools = getEmptyPoolsInfo(maxExistingPriority).slice(existingPools.length); + // eslint-disable-next-line react-hooks/set-state-in-effect + setPools([...currentPools, ...emptyPools]); + }, [existingPools]); + + // 0 is the default pool, 1 and 2 are backup pools + const [currentPoolIndex, setCurrentPoolIndex] = useState(null); + + const [warnDefaultPool, setWarnDefaultPool] = useState(false); + + const handleSaveError = (error: string) => { + // TODO better error handling + console.error("Error saving pool:", error); + }; + + const handleContinue = useCallback(() => { + // check if default pool has been entered + const noValidDefaultPool = !isValidPool(pools[0]); + if (noValidDefaultPool) { + setWarnDefaultPool(true); + return; + } + + onSaveRequested?.(); + setLoading(true); + + const validPools = pools.filter((pool) => isValidPool(pool)); + const apiCalls = validPools.map((pool) => { + if (pool.poolId === undefined) { + // create new pool + const createPoolRequest = create(CreatePoolRequestSchema, { + poolConfig: { + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: pool.password, + }, + }); + return () => createPool({ createPoolRequest, onError: handleSaveError }); + } else { + // update existing pool + const updatePoolRequest = create(UpdatePoolRequestSchema, { + poolId: BigInt(pool.poolId), + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: pool.password, + }); + return () => updatePool({ updatePoolRequest, onError: handleSaveError }); + } + }); + + // handle deleted pools + existingPools.forEach((pool) => { + // intentionally convert bigint to number for comparison + const foundPool = validPools.find((p) => p.poolId == pool.poolId); + if (foundPool === undefined) { + // delete pool + const deletePoolRequest = create(DeletePoolRequestSchema, { + poolId: pool.poolId, + }); + apiCalls.push(() => { + return deletePool({ deletePoolRequest, onError: handleSaveError }); + }); + } + }); + + apiCalls[0]().then(() => { + // wait for default pool to be saved before saving backup pools + const promises = apiCalls.slice(1).map((apiCall) => { + return apiCall(); + }); + Promise.all(promises) + .then(async () => { + await refetchOnboardingStatus(); + onSaveDone(); + }) + .catch(() => onSaveFailed?.()) + .finally(() => { + setLoading(false); + }); + }); + }, [ + pools, + onSaveRequested, + existingPools, + createPool, + updatePool, + deletePool, + onSaveDone, + onSaveFailed, + refetchOnboardingStatus, + ]); + + const onChangePools = useCallback((newPools: PoolInfo[]) => { + setPools(newPools); + if (isValidPool(newPools[0])) { + setWarnDefaultPool(false); + } + }, []); + + const savePool = useCallback( + async (pool: PoolInfo, isPasswordSet: boolean) => { + // Only send password if it was set + const passwordToSend = isPasswordSet ? pool.password : ""; + + if (pool.poolId === undefined) { + // create new pool + const createPoolRequest = create(CreatePoolRequestSchema, { + poolConfig: { + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: passwordToSend, + }, + }); + await createPool({ createPoolRequest, onError: handleSaveError }); + } else { + // update existing pool + const updatePoolRequest = create(UpdatePoolRequestSchema, { + poolId: BigInt(pool.poolId), + poolName: pool.name || "", + url: pool.url, + username: pool.username, + password: passwordToSend, + }); + await updatePool({ updatePoolRequest, onError: handleSaveError }); + } + }, + [createPool, updatePool], + ); + + // TODO support connection test + return ( +
+
+ setWarnDefaultPool(false)} show={warnDefaultPool} /> + +
+ {[...Array(3)].map((_, index) => { + const poolIndex = index as PoolIndex; + return ( + setCurrentPoolIndex(poolIndex)} + testId={`pool-${poolIndex}-add-button`} + /> + ); + })} + + setCurrentPoolIndex(null)} + poolIndex={(currentPoolIndex ?? 0) as BackupPoolIndex} + pools={pools} + isTestingConnection={validatePoolPending} + testConnection={validatePool} + onSave={savePool} + disallowUsernameSeparator + /> +
+ + +
+ ); +}; + +export default MiningPoolsForm; diff --git a/client/src/protoFleet/components/MiningPools/index.ts b/client/src/protoFleet/components/MiningPools/index.ts new file mode 100644 index 000000000..9a1c74d79 --- /dev/null +++ b/client/src/protoFleet/components/MiningPools/index.ts @@ -0,0 +1,3 @@ +import MiningPoolsForm from "./MiningPoolsForm"; + +export default MiningPoolsForm; diff --git a/client/src/protoFleet/components/NavigationMenu/FloatingNavigation.tsx b/client/src/protoFleet/components/NavigationMenu/FloatingNavigation.tsx new file mode 100644 index 000000000..7dbca431c --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/FloatingNavigation.tsx @@ -0,0 +1,53 @@ +import { useCallback, useLayoutEffect, useState } from "react"; +import clsx from "clsx"; +import Navigation from "@/protoFleet/components/NavigationMenu/Navigation"; +import { NavItem } from "@/protoFleet/config/navItems"; +import { usePreventScroll } from "@/shared/hooks/usePreventScroll"; + +type FloatingNavigationProps = { + items: NavItem[]; + closeMenu?: () => void; +}; + +const FloatingNavigation = ({ items, closeMenu }: FloatingNavigationProps) => { + const [isVisible, setIsVisible] = useState(true); + const { preventScroll } = usePreventScroll(); + useLayoutEffect(() => { + preventScroll(); + }, [preventScroll]); + + const handleCloseMenu = useCallback(() => { + setIsVisible(false); + setTimeout(() => { + closeMenu?.(); + }, 250); + }, [closeMenu]); + + return ( +
+
+ ); +}; + +export default FloatingNavigation; diff --git a/client/src/protoFleet/components/NavigationMenu/Navigation.tsx b/client/src/protoFleet/components/NavigationMenu/Navigation.tsx new file mode 100644 index 000000000..b8c252e32 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/Navigation.tsx @@ -0,0 +1,255 @@ +import { AnimatePresence, motion } from "motion/react"; +import { createElement, useCallback, useMemo, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import clsx from "clsx"; +import { useLogoutAction } from "@/protoFleet/api/useLogout"; +import { NavItem, secondaryNavItems } from "@/protoFleet/config/navItems"; +import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; +import { useRole } from "@/protoFleet/store"; +import { Logo, LogoAlt } from "@/shared/assets/icons"; +import { ArrowLeftCompact } from "@/shared/assets/icons"; +import MorphingPlusMinus from "@/shared/components/MorphingPlusMinus"; +import useCssVariable from "@/shared/hooks/useCssVariable"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; +import { cubicBezierValues } from "@/shared/utils/cssUtils"; +import { stripLeadingSlash } from "@/shared/utils/stringUtils"; + +type NavigationProps = { + items: NavItem[]; + className?: string; + closeMenu?: () => void; +}; + +const Navigation = ({ items, className, closeMenu }: NavigationProps) => { + const { pathname } = useLocation(); + const { isPhone, isTablet } = useWindowDimensions(); + const logout = useLogoutAction(); + const { bg } = usePageBackground(); + const currentRole = useRole(); + const [settingsManuallyToggled, setSettingsManuallyToggled] = useState(false); + const [showSettingsHover, setShowSettingsHover] = useState(false); + + const easeGentle = useCssVariable("--ease-gentle", cubicBezierValues); + + const homeItem = useMemo(() => items.find((item) => item.label === "Home"), [items]); + const settingsItem = useMemo(() => items.find((item) => item.label === "Settings"), [items]); + + // Check if current page is a settings sub-item + const isOnSettingsSubPage = useMemo(() => { + const _pathname = stripLeadingSlash(pathname); + return secondaryNavItems + .filter((nav) => nav.parent === "/settings") + .some((nav) => { + const _navPath = stripLeadingSlash(nav.path); + return _pathname === _navPath || _pathname.startsWith(`${_navPath}/`); + }); + }, [pathname]); + + // Derive expanded state: auto-expand if on settings page OR manually toggled + const isSettingsExpanded = settingsManuallyToggled || isOnSettingsSubPage; + + const handleSettingsHover = useCallback((hover: boolean) => { + setShowSettingsHover(hover); + }, []); + + const isCurrentPath = (path: string) => { + const _pathname = stripLeadingSlash(pathname); + const _path = stripLeadingSlash(path); + return _pathname === _path || _pathname.startsWith(`${_path}/`); + }; + + return ( + + ); +}; + +export default Navigation; diff --git a/client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx new file mode 100644 index 000000000..2a8a0d67a --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.stories.tsx @@ -0,0 +1,26 @@ +import { ElementType } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { action } from "storybook/actions"; +import NavigationMenuComponent from "."; +import { primaryNavItems } from "@/protoFleet/config/navItems"; + +export const NavigationMenu = () => { + return ; +}; + +export default { + title: "Proto Fleet/NavigationMenu", + parameters: { + withRouter: false, + }, + args: {}, + argTypes: {}, + decorators: [ + (Story: ElementType) => ( + + + + ), + ], +}; diff --git a/client/src/protoFleet/components/NavigationMenu/NavigationMenu.test.tsx b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.test.tsx new file mode 100644 index 000000000..4ef1112d0 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.test.tsx @@ -0,0 +1,59 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import NavigationMenu from "./NavigationMenu"; +import { NavItem } from "@/protoFleet/config/navItems"; + +const { mockUseWindowDimensions } = vi.hoisted(() => ({ + mockUseWindowDimensions: vi.fn(), +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: mockUseWindowDimensions, +})); + +describe("Navigation Menu", () => { + const items: NavItem[] = [ + { + path: "/foo", + label: "Foo", + }, + { + path: "/bar", + label: "Bar", + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + }); + + it("should render the correct number nav items", () => { + const { getByTestId } = render( + + + , + ); + + const navMenu = getByTestId("navigation-menu"); + const navItems = navMenu.querySelectorAll("li"); + expect(navItems.length).toBe(2); + }); + + it("should show the correct active nav item", async () => { + const { getByText } = render( + + + , + ); + + const currentItem = getByText("Foo").closest("a"); + await waitFor(() => { + expect(currentItem).toHaveClass("bg-core-primary-5"); + }); + }); +}); diff --git a/client/src/protoFleet/components/NavigationMenu/NavigationMenu.tsx b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.tsx new file mode 100644 index 000000000..393b67f15 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/NavigationMenu.tsx @@ -0,0 +1,25 @@ +import FloatingNavigation from "@/protoFleet/components/NavigationMenu/FloatingNavigation"; +import Navigation from "@/protoFleet/components/NavigationMenu/Navigation"; +import { NavItem } from "@/protoFleet/config/navItems"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; + +type NavigationMenuProps = { + items: NavItem[]; + isVisible?: boolean; + closeMenu?: () => void; +}; + +const NavigationMenu = ({ items, isVisible, closeMenu }: NavigationMenuProps) => { + const { isPhone, isTablet } = useWindowDimensions(); + + if (isPhone || isTablet) { + if (isVisible) { + return ; + } + return null; + } + + return ; +}; + +export default NavigationMenu; diff --git a/client/src/protoFleet/components/NavigationMenu/constants.ts b/client/src/protoFleet/components/NavigationMenu/constants.ts new file mode 100644 index 000000000..90cca4d7c --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/constants.ts @@ -0,0 +1,28 @@ +import { Fleet, Graph, Home, Logs, Repair, Settings } from "@/shared/assets/icons"; + +export const navigationItems = { + home: { + route: "", + icon: Home, + }, + fleet: { + route: "containers", + icon: Fleet, + }, + profitability: { + route: "profitability", + icon: Graph, + }, + repairs: { + route: "repairs", + icon: Repair, + }, + logs: { + route: "logs", + icon: Logs, + }, + settings: { + route: "settings/settings", + icon: Settings, + }, +} as const; diff --git a/client/src/protoFleet/components/NavigationMenu/index.ts b/client/src/protoFleet/components/NavigationMenu/index.ts new file mode 100644 index 000000000..64d49aed6 --- /dev/null +++ b/client/src/protoFleet/components/NavigationMenu/index.ts @@ -0,0 +1,3 @@ +import NavigationMenu from "./NavigationMenu"; + +export default NavigationMenu; diff --git a/client/src/protoFleet/components/NoFilterResultsEmptyState.tsx b/client/src/protoFleet/components/NoFilterResultsEmptyState.tsx new file mode 100644 index 000000000..d0a84a664 --- /dev/null +++ b/client/src/protoFleet/components/NoFilterResultsEmptyState.tsx @@ -0,0 +1,30 @@ +import Button, { sizes, variants } from "@/shared/components/Button"; + +interface NoFilterResultsEmptyStateProps { + hasActiveFilters?: boolean; + onClearFilters?: () => void; +} + +const NoFilterResultsEmptyState = ({ hasActiveFilters = false, onClearFilters }: NoFilterResultsEmptyStateProps) => ( +
+
No results
+ {hasActiveFilters && ( + <> +

Try adjusting or clearing your filters.

+ {onClearFilters && ( + + )} + + )} +
+); + +export default NoFilterResultsEmptyState; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.stories.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.stories.tsx new file mode 100644 index 000000000..2312ab219 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.stories.tsx @@ -0,0 +1,21 @@ +import BankBalanceComponent from "./BankBalance"; + +interface BankBalanceArgs { + loading: boolean; + balance: number; +} + +export const BankBalance = ({ loading, balance }: BankBalanceArgs) => { + return ; +}; + +export default { + title: "Proto Fleet/Page Header/Bank Balance", + args: { + loading: false, + balance: 1630, + }, + argTypes: { + balance: { control: { type: "number", min: 0 } }, + }, +}; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.test.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.test.tsx new file mode 100644 index 000000000..f5657edcf --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.test.tsx @@ -0,0 +1,24 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import BankBalance from "./BankBalance"; +import { bitcoinCurrency } from "./constants"; + +describe("Bank Balance", () => { + const bankIconTestId = "bank-account-icon"; + const skeletonTestId = "skeleton-bar"; + + test("renders loading state", () => { + const { getByTestId } = render(); + + expect(getByTestId(bankIconTestId)).toBeDefined(); + expect(getByTestId(skeletonTestId)).toBeDefined(); + }); + + test("renders balance with currency", () => { + const { queryByText, getByTestId, queryByTestId } = render(); + + expect(getByTestId(bankIconTestId)).toBeDefined(); + expect(queryByTestId(skeletonTestId)).toBeNull(); + expect(queryByText(bitcoinCurrency, { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.tsx new file mode 100644 index 000000000..9bc1d701a --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalance.tsx @@ -0,0 +1,29 @@ +import { bitcoinCurrency } from "./constants"; +import { BankAccount } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Chip from "@/shared/components/Chip"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +import { getDisplayValue } from "@/shared/utils/stringUtils"; + +interface BankBalanceProps { + balance?: number; + loading?: boolean; +} + +const BankBalance = ({ balance, loading }: BankBalanceProps) => { + const formattedBalance = () => { + if (balance === undefined || balance === null) return; + + if (balance > 1000) return getDisplayValue(balance / 1000) + "k"; + return getDisplayValue(balance); + }; + + return ( + }> + {loading ? : <>{bitcoinCurrency + formattedBalance()}} + + ); +}; + +export default BankBalance; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/BankBalanceWrapper.tsx b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalanceWrapper.tsx new file mode 100644 index 000000000..a523b4fa9 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/BankBalanceWrapper.tsx @@ -0,0 +1,11 @@ +import BankBalance from "./BankBalance"; + +const BankBalanceWrapper = () => { + // TODO load balance from API + const balance = 1630; + const loading = false; + + return ; +}; + +export default BankBalanceWrapper; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/constants.ts b/client/src/protoFleet/components/PageHeader/BankBalance/constants.ts new file mode 100644 index 000000000..52a02c3de --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/constants.ts @@ -0,0 +1 @@ +export const bitcoinCurrency = "₿"; diff --git a/client/src/protoFleet/components/PageHeader/BankBalance/index.ts b/client/src/protoFleet/components/PageHeader/BankBalance/index.ts new file mode 100644 index 000000000..3d85019a5 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BankBalance/index.ts @@ -0,0 +1,3 @@ +import BankBalanceWrapper from "./BankBalanceWrapper"; + +export default BankBalanceWrapper; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.stories.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.stories.tsx new file mode 100644 index 000000000..44ff64c72 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.stories.tsx @@ -0,0 +1,21 @@ +import BitcoinExchangeRateComponent from "./BitcoinExchangeRate"; + +interface BitcoinExchangeRateArgs { + loading: boolean; + exchangeRate: number; +} + +export const BitcoinExchangeRate = ({ loading, exchangeRate }: BitcoinExchangeRateArgs) => { + return ; +}; + +export default { + title: "Proto Fleet/Page Header/Bitcoin Exchange Rate", + args: { + loading: false, + exchangeRate: 89729.88, + }, + argTypes: { + exchangeRate: { control: { type: "number", min: 0 } }, + }, +}; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.test.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.test.tsx new file mode 100644 index 000000000..201401e2b --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.test.tsx @@ -0,0 +1,27 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import BitcoinExchangeRate from "./BitcoinExchangeRate"; + +describe("Bank Balance", () => { + const bitcoinIconTestId = "bitcoin-icon"; + const skeletonTestId = "skeleton-bar"; + + const usdCurrency = "$"; + + test("renders loading state", () => { + const { getByTestId } = render(); + + expect(getByTestId(bitcoinIconTestId)).toBeDefined(); + expect(getByTestId(skeletonTestId)).toBeDefined(); + }); + + test("renders exchange rate with currency", () => { + const { queryByText, getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(bitcoinIconTestId)).toBeDefined(); + expect(queryByTestId(skeletonTestId)).toBeNull(); + expect(queryByText(usdCurrency, { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.tsx new file mode 100644 index 000000000..5e4ce9a49 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRate.tsx @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +import { Bitcoin } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Chip from "@/shared/components/Chip"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface BitcoinExchangeRateProps { + exchangeRate?: number; + loading?: boolean; +} + +const BitcoinExchangeRate = ({ exchangeRate, loading }: BitcoinExchangeRateProps) => { + const formattedRate = useMemo(() => { + if (exchangeRate === undefined || exchangeRate === null) return; + + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(exchangeRate); + }, [exchangeRate]); + + return ( + }> + {loading ? : <>{formattedRate}} + + ); +}; + +export default BitcoinExchangeRate; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRateWrapper.tsx b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRateWrapper.tsx new file mode 100644 index 000000000..ec52e78f3 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/BitcoinExchangeRateWrapper.tsx @@ -0,0 +1,10 @@ +import BitcoinExchangeRate from "./BitcoinExchangeRate"; + +const BitcoinExchangeRateWrapper = () => { + const exchangeRate = 89729.88; + const loading = false; + + return ; +}; + +export default BitcoinExchangeRateWrapper; diff --git a/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/index.ts b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/index.ts new file mode 100644 index 000000000..6f889df49 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/BitcoinExchangeRate/index.ts @@ -0,0 +1,3 @@ +import BitcoinExchangeRateWrapper from "./BitcoinExchangeRateWrapper"; + +export default BitcoinExchangeRateWrapper; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.stories.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.stories.tsx new file mode 100644 index 000000000..0c0b93318 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.stories.tsx @@ -0,0 +1,18 @@ +import LocationSelectorComponent from "./LocationSelector"; + +interface LocationSelectorArgs { + loading: boolean; + location: string; +} + +export const LocationSelector = ({ loading, location }: LocationSelectorArgs) => { + return ; +}; + +export default { + title: "Proto Fleet/Page Header/Location Selector", + args: { + loading: false, + location: "ProtoFleet test lab", + }, +}; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.test.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.test.tsx new file mode 100644 index 000000000..bb6ec91cd --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.test.tsx @@ -0,0 +1,22 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import LocationSelector from "./LocationSelector"; + +describe("Location Selector", () => { + const skeletonTestId = "skeleton-bar"; + + const locationName = "Test lab"; + + test("renders loading state", () => { + const { getByTestId } = render(); + + expect(getByTestId(skeletonTestId)).toBeDefined(); + }); + + test("renders location name", () => { + const { queryByText, queryByTestId } = render(); + + expect(queryByTestId(skeletonTestId)).toBeNull(); + expect(queryByText(locationName)).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.tsx new file mode 100644 index 000000000..2b7691a7b --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelector.tsx @@ -0,0 +1,13 @@ +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface LocationSelectorProps { + location?: string; + loading?: boolean; +} + +const LocationSelector = ({ location, loading }: LocationSelectorProps) => { + // TODO implement selector with options + return
{loading ? : location}
; +}; + +export default LocationSelector; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelectorWrapper.tsx b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelectorWrapper.tsx new file mode 100644 index 000000000..897c66177 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/LocationSelectorWrapper.tsx @@ -0,0 +1,11 @@ +import LocationSelector from "./LocationSelector"; + +const LocationSelectorWrapper = () => { + // TODO load location from API + const location = "Proto Fleet Beta"; + const loading = false; + + return ; +}; + +export default LocationSelectorWrapper; diff --git a/client/src/protoFleet/components/PageHeader/LocationSelector/index.ts b/client/src/protoFleet/components/PageHeader/LocationSelector/index.ts new file mode 100644 index 000000000..c0649225e --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/LocationSelector/index.ts @@ -0,0 +1,3 @@ +import LocationSelectorWrapper from "./LocationSelectorWrapper"; + +export default LocationSelectorWrapper; diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx new file mode 100644 index 000000000..046cfaed4 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/PageHeader.stories.tsx @@ -0,0 +1,214 @@ +import { type ReactNode, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { action } from "storybook/actions"; + +import SchedulePillComponent from "./SchedulePill"; +import { buildSchedulePopoverSections, selectPillSchedule } from "./schedulePillUtils"; +import type { UseSchedulePillDataResult } from "./useSchedulePillData"; +import PageHeaderComponent from "."; +import { + DayOfWeek, + PowerTargetMode, + RecurrenceFrequency, + ScheduleTargetType, + ScheduleType, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import { + ScheduleAction as ProtoScheduleAction, + ScheduleSchema, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { Schedule as ProtoSchedule } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleAction, ScheduleListItem, ScheduleStatus } from "@/protoFleet/api/useScheduleApi"; + +const HOUR_IN_MS = 60 * 60 * 1000; +const DAY_IN_MS = 24 * HOUR_IN_MS; +const QUARTER_HOUR_IN_MS = 15 * 60 * 1000; + +const roundDateToQuarterHour = (date: Date) => + new Date(Math.ceil(date.getTime() / QUARTER_HOUR_IN_MS) * QUARTER_HOUR_IN_MS); + +const toTimestamp = (date: Date) => ({ + seconds: BigInt(Math.floor(date.getTime() / 1000)), + nanos: 0, +}); + +const createTargets = (count: number) => + Array.from({ length: count }, (_, index) => ({ + targetType: ScheduleTargetType.MINER, + targetId: `miner-${index + 1}`, + })); + +const createScheduleListItem = ({ + id, + name, + priority, + status, + action, + startTime, + endTime, + nextRunAt, + targetSummary, + powerTargetMode, +}: { + id: string; + name: string; + priority: number; + status: ScheduleStatus; + action: ScheduleAction; + startTime: string; + endTime?: string; + nextRunAt?: Date; + targetSummary: string; + powerTargetMode?: PowerTargetMode; +}): ScheduleListItem => { + const protoAction = + action === "setPowerTarget" + ? ProtoScheduleAction.SET_POWER_TARGET + : action === "sleep" + ? ProtoScheduleAction.SLEEP + : ProtoScheduleAction.REBOOT; + + return { + id, + priority, + name, + targetSummary, + scheduleSummary: "Story schedule", + nextRunSummary: nextRunAt ? `Runs on ${nextRunAt.toLocaleString()}` : null, + action, + status, + createdBy: "Storybook", + rawSchedule: create(ScheduleSchema, { + id: BigInt(id), + name, + action: protoAction, + actionConfig: powerTargetMode + ? { + mode: powerTargetMode, + } + : undefined, + targets: createTargets(3), + nextRunAt: nextRunAt ? toTimestamp(nextRunAt) : undefined, + scheduleType: ScheduleType.RECURRING, + recurrence: { + frequency: RecurrenceFrequency.WEEKLY, + daysOfWeek: [DayOfWeek.SATURDAY, DayOfWeek.SUNDAY], + }, + startDate: "2026-04-07", + startTime, + endTime, + timezone: "UTC", + }) as ProtoSchedule, + }; +}; + +const buildStorySchedules = () => { + const roundedNow = roundDateToQuarterHour(new Date()).getTime(); + return [ + createScheduleListItem({ + id: "1", + name: "Weekday ramp-up", + priority: 1, + status: "running", + action: "setPowerTarget", + startTime: "06:00", + endTime: "22:00", + targetSummary: "Applies to 3 miners", + powerTargetMode: PowerTargetMode.MAX, + }), + createScheduleListItem({ + id: "2", + name: "Night shift", + priority: 2, + status: "active", + action: "sleep", + startTime: "22:00", + nextRunAt: new Date(roundedNow + 9 * HOUR_IN_MS + 30 * 60 * 1000), + targetSummary: "Applies to 3 miners", + }), + createScheduleListItem({ + id: "3", + name: "Weekend reboot", + priority: 3, + status: "paused", + action: "reboot", + startTime: "21:45", + nextRunAt: new Date(roundedNow + 4 * DAY_IN_MS + 8 * HOUR_IN_MS + 15 * 60 * 1000), + targetSummary: "Applies to 3 miners", + }), + ]; +}; + +const getToggledStatus = (status: ScheduleStatus): ScheduleStatus => { + switch (status) { + case "paused": + return "active"; + case "running": + case "active": + default: + return "paused"; + } +}; + +const InteractiveSchedulePillStory = () => { + const [schedules, setSchedules] = useState(() => buildStorySchedules()); + const [pendingScheduleId, setPendingScheduleId] = useState(null); + const sections = buildSchedulePopoverSections(schedules); + const pillSchedule = selectPillSchedule(sections); + + if (!pillSchedule) { + throw new Error("Story data is missing a pill schedule"); + } + + const handleToggleScheduleStatus = async (schedule: ScheduleListItem) => { + const nextStatus = getToggledStatus(schedule.status); + setPendingScheduleId(schedule.id); + action("toggle schedule status")(`${schedule.name}: ${schedule.status} -> ${nextStatus}`); + await new Promise((resolve) => { + window.setTimeout(resolve, 200); + }); + setSchedules((currentSchedules) => + currentSchedules.map((currentSchedule) => + currentSchedule.id === schedule.id ? { ...currentSchedule, status: nextStatus } : currentSchedule, + ), + ); + setPendingScheduleId(null); + }; + + return ( + + ); +}; + +const StoryFrame = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +const emptySchedulePillData: UseSchedulePillDataResult = { + hasVisibleSchedules: false, + pillSchedule: null, + sections: [], + pendingScheduleId: null, + onToggleScheduleStatus: async () => {}, +}; + +export const PageHeader = () => { + return ; +}; + +export const SchedulePill = () => { + return ( + + + + ); +}; + +export default { + title: "Proto Fleet/Page Header", +}; diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx new file mode 100644 index 000000000..9fa7cd721 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx @@ -0,0 +1,88 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import PageHeader from "./PageHeader"; +import type { UseSchedulePillDataResult } from "./useSchedulePillData"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; + +const mockUseWindowDimensions = vi.fn(); +const mockUseReactiveLocalStorage = vi.fn(); + +vi.mock("./LocationSelector", () => ({ + default: () =>
Location selector
, +})); + +vi.mock("./SchedulePill", () => ({ + __esModule: true, + default: ({ pillSchedule }: { pillSchedule: { name: string } }) =>
{pillSchedule.name}
, +})); + +vi.mock("@/shared/hooks/useWindowDimensions", () => ({ + useWindowDimensions: () => mockUseWindowDimensions(), +})); + +vi.mock("@/shared/hooks/useReactiveLocalStorage", () => ({ + useReactiveLocalStorage: () => mockUseReactiveLocalStorage(), +})); + +vi.mock("@/shared/assets/icons", () => ({ + Pause: ({ ariaLabel }: { ariaLabel?: string }) => , +})); +const createPillSchedule = (name: string): ScheduleListItem => + ({ + id: "1", + priority: 1, + name, + targetSummary: "Applies to all miners", + scheduleSummary: "Weekdays · 10:00 PM", + nextRunSummary: "Runs tomorrow at 10:00 PM", + action: "sleep", + status: "active", + createdBy: "Review", + rawSchedule: {}, + }) as ScheduleListItem; + +const createSchedulePillData = (overrides: Partial = {}): UseSchedulePillDataResult => ({ + hasVisibleSchedules: false, + pillSchedule: null, + sections: [], + pendingScheduleId: null, + onToggleScheduleStatus: vi.fn(), + ...overrides, +}); + +describe("PageHeader", () => { + beforeEach(() => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: true, + isTablet: false, + }); + mockUseReactiveLocalStorage.mockReturnValue([false, vi.fn()]); + }); + + it("shows the phone widget row when schedules are available even if setup is not dismissed", () => { + const schedulePillData = createSchedulePillData({ + hasVisibleSchedules: true, + pillSchedule: createPillSchedule("Night reboot"), + }); + + render( + + + , + ); + + expect(screen.getByText("Night reboot")).toBeVisible(); + }); + + it("keeps the phone widget row hidden when neither setup nor schedules need space", () => { + render( + + + , + ); + + expect(screen.queryByText("Continue setup")).not.toBeInTheDocument(); + expect(screen.queryByText("Night reboot")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.tsx new file mode 100644 index 000000000..e23f9a0f7 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/PageHeader.tsx @@ -0,0 +1,93 @@ +import clsx from "clsx"; +import LocationSelector from "./LocationSelector"; +import SchedulePill from "./SchedulePill"; +import type { UseSchedulePillDataResult } from "./useSchedulePillData"; +import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; +import { Pause } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; +interface PageHeaderProps { + isMenuOpen?: boolean; + openMenu?: () => void; + schedulePillData: UseSchedulePillDataResult; +} + +const headerWidgetEnabled = true; + +const HeaderWidgets = ({ + className, + dismissedSetup, + onContinueSetup, + schedulePillData, +}: { + className?: string; + dismissedSetup: boolean; + onContinueSetup: () => void; + schedulePillData: UseSchedulePillDataResult; +}) => { + const { pillSchedule, sections, pendingScheduleId, onToggleScheduleStatus } = schedulePillData; + + return ( +
+ {pillSchedule ? ( + + ) : null} + {dismissedSetup ? ( +
+ ); +}; + +const PageHeader = ({ isMenuOpen, openMenu, schedulePillData }: PageHeaderProps) => { + const { isPhone, isTablet } = useWindowDimensions(); + const { bgClass } = usePageBackground(); + const [dismissedSetup, setDismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); + const hasDismissedSetup = Boolean(dismissedSetup); + + const handleCompleteSetup = () => { + setDismissedSetup(false); + }; + + const headerWidgetsProps = { + dismissedSetup: hasDismissedSetup, + onContinueSetup: handleCompleteSetup, + schedulePillData, + }; + const showPhoneWidgets = isPhone && (hasDismissedSetup || schedulePillData.hasVisibleSchedules); + + return ( + <> +
+
+
+ {(isPhone || isTablet) && ( + + )} + +
+ {!isPhone && headerWidgetEnabled && } +
+
+ {showPhoneWidgets && ( +
+ +
+ )} + + ); +}; + +export default PageHeader; diff --git a/client/src/protoFleet/components/PageHeader/SchedulePill.test.tsx b/client/src/protoFleet/components/PageHeader/SchedulePill.test.tsx new file mode 100644 index 000000000..e6a310869 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePill.test.tsx @@ -0,0 +1,210 @@ +import type { ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; + +import SchedulePill from "./SchedulePill"; +import { + buildSchedulePopoverSections, + getSchedulePopoverActionSummary, + getSchedulePopoverTargetSummary, + selectPillSchedule, +} from "./schedulePillUtils"; +import { + PowerTargetMode, + ScheduleAction as ProtoScheduleAction, + ScheduleSchema, +} from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { Schedule as ProtoSchedule } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleAction, ScheduleListItem, ScheduleStatus } from "@/protoFleet/api/useScheduleApi"; + +vi.mock("./SchedulePopover", () => ({ + __esModule: true, + default: () =>
Popover content
, +})); + +vi.mock("@/shared/components/Popover", () => ({ + __esModule: true, + default: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverProvider: ({ children }: { children: ReactNode }) => <>{children}, + popoverSizes: { + small: "small", + medium: "medium", + normal: "normal", + }, + useResponsivePopover: () => ({ + triggerRef: { current: null }, + }), +})); + +const createScheduleListItem = ({ + id, + name, + priority, + status, + action = "reboot", + powerTargetMode = PowerTargetMode.DEFAULT, + nextRunAt, +}: { + id: string; + name: string; + priority: number; + status: ScheduleStatus; + action?: ScheduleAction; + powerTargetMode?: PowerTargetMode; + nextRunAt?: Date; +}): ScheduleListItem => { + const protoAction = + action === "setPowerTarget" + ? ProtoScheduleAction.SET_POWER_TARGET + : action === "sleep" + ? ProtoScheduleAction.SLEEP + : ProtoScheduleAction.REBOOT; + + return { + id, + priority, + name, + targetSummary: "Applies to 1 rack", + scheduleSummary: "Weekdays · 10:00 PM", + nextRunSummary: "Runs tomorrow at 10:00 PM", + action, + status, + createdBy: "Negar", + rawSchedule: create(ScheduleSchema, { + id: BigInt(id), + name, + action: protoAction, + actionConfig: { + mode: powerTargetMode, + }, + nextRunAt: nextRunAt + ? { + seconds: BigInt(Math.floor(nextRunAt.getTime() / 1000)), + nanos: 0, + } + : undefined, + startDate: "2026-04-07", + startTime: "22:00", + timezone: "UTC", + }) as ProtoSchedule, + }; +}; + +describe("SchedulePill helpers", () => { + it("groups schedules by header priority and limits the popover to three entries", () => { + const sections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "1", name: "Paused low", priority: 5, status: "paused" }), + createScheduleListItem({ id: "2", name: "Running second", priority: 2, status: "running" }), + createScheduleListItem({ id: "3", name: "Active first", priority: 1, status: "active" }), + createScheduleListItem({ id: "4", name: "Running first", priority: 1, status: "running" }), + createScheduleListItem({ id: "5", name: "Completed", priority: 3, status: "completed" }), + createScheduleListItem({ id: "6", name: "Active second", priority: 4, status: "active" }), + ]); + + expect(sections).toHaveLength(2); + expect(sections[0]?.title).toBe("Active now"); + expect(sections[0]?.schedules.map((schedule) => schedule.name)).toEqual(["Running first", "Running second"]); + expect(sections[1]?.title).toBe("Up next"); + expect(sections[1]?.schedules.map((schedule) => schedule.name)).toEqual(["Active first"]); + }); + + it("selects the pill schedule with running schedules first, then active, then paused", () => { + const runningSections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "1", name: "Paused", priority: 2, status: "paused" }), + createScheduleListItem({ id: "2", name: "Running", priority: 3, status: "running" }), + createScheduleListItem({ id: "3", name: "Active", priority: 1, status: "active" }), + ]); + + const activeSections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "4", name: "Paused", priority: 2, status: "paused" }), + createScheduleListItem({ id: "5", name: "Active", priority: 1, status: "active" }), + ]); + + const pausedSections = buildSchedulePopoverSections([ + createScheduleListItem({ id: "6", name: "Paused only", priority: 1, status: "paused" }), + ]); + + expect(selectPillSchedule(runningSections)?.name).toBe("Running"); + expect(selectPillSchedule(activeSections)?.name).toBe("Active"); + expect(selectPillSchedule(pausedSections)?.name).toBe("Paused only"); + }); + + it("keeps the pill label live while the popover is open", () => { + const runningSchedule = createScheduleListItem({ id: "1", name: "Running first", priority: 1, status: "running" }); + const activeSchedule = createScheduleListItem({ id: "2", name: "Active next", priority: 1, status: "active" }); + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "View schedule details for Running first" })); + + expect(screen.getByText("Popover content")).toBeInTheDocument(); + expect(screen.getByText("Running first")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText("Popover content")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "View schedule details for Active next" })).toBeInTheDocument(); + expect(screen.getByText("Active next")).toBeInTheDocument(); + expect(screen.queryByText("Running first")).not.toBeInTheDocument(); + }); + + it("uses the next scheduled occurrence in paused action summaries", () => { + const nextRunAt = new Date("2026-04-11T22:00:00.000Z"); + const schedule = createScheduleListItem({ + id: "7", + name: "Weekend sleep", + priority: 1, + status: "paused", + action: "sleep", + nextRunAt, + }); + const expectedDateTime = new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(nextRunAt); + + expect(getSchedulePopoverActionSummary("paused", schedule)).toBe(`Sleep · ${expectedDateTime}`); + }); + + it("keeps the fleet-wide target summary for schedules that apply to all miners", () => { + const schedule = { + ...createScheduleListItem({ + id: "8", + name: "Fleet wide sleep", + priority: 1, + status: "active", + action: "sleep", + }), + targetSummary: "Applies to all miners", + rawSchedule: create(ScheduleSchema, { + id: 8n, + name: "Fleet wide sleep", + action: ProtoScheduleAction.SLEEP, + targets: [], + startDate: "2026-04-07", + startTime: "22:00", + timezone: "UTC", + }) as ProtoSchedule, + }; + + expect(getSchedulePopoverTargetSummary(schedule)).toBe("Applies to all miners"); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/SchedulePill.tsx b/client/src/protoFleet/components/PageHeader/SchedulePill.tsx new file mode 100644 index 000000000..ad1028499 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePill.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import clsx from "clsx"; + +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import type { SchedulePopoverSection } from "@/protoFleet/components/PageHeader/schedulePillUtils"; +import SchedulePopover from "@/protoFleet/components/PageHeader/SchedulePopover"; +import { scheduleStatusDotClassName } from "@/protoFleet/features/settings/components/Schedules/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Popover, { PopoverProvider, popoverSizes, useResponsivePopover } from "@/shared/components/Popover"; +import { positions } from "@/shared/constants"; + +interface SchedulePillProps { + pillSchedule: ScheduleListItem; + sections: SchedulePopoverSection[]; + pendingScheduleId: string | null; + onToggleScheduleStatus: (schedule: ScheduleListItem) => Promise; +} + +const SchedulePillContent = ({ + pillSchedule, + sections, + pendingScheduleId, + onToggleScheduleStatus, +}: SchedulePillProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { triggerRef } = useResponsivePopover(); + + return ( +
+ + + {isPopoverOpen ? ( + setIsPopoverOpen(false)} + closeIgnoreSelectors={[".schedule-pill-trigger"]} + > + setIsPopoverOpen(false)} + /> + + ) : null} +
+ ); +}; + +const SchedulePill = (props: SchedulePillProps) => ( + + + +); + +export default SchedulePill; diff --git a/client/src/protoFleet/components/PageHeader/SchedulePopover.test.tsx b/client/src/protoFleet/components/PageHeader/SchedulePopover.test.tsx new file mode 100644 index 000000000..b2fcaa896 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePopover.test.tsx @@ -0,0 +1,73 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; + +import SchedulePopover from "./SchedulePopover"; +import { ScheduleSchema } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; + +const createSchedule = (id: string, name: string, status: ScheduleListItem["status"]): ScheduleListItem => ({ + id, + priority: Number(id), + name, + targetSummary: "Applies to 1 miner", + scheduleSummary: "Weekdays · 10:00 PM", + nextRunSummary: "Runs tomorrow at 10:00 PM", + action: "reboot", + status, + createdBy: "Review", + rawSchedule: create(ScheduleSchema, { + id: BigInt(id), + name, + startDate: "2026-04-07", + startTime: "22:00", + timezone: "UTC", + }), +}); + +describe("SchedulePopover", () => { + it("disables every toggle button while a schedule update is in flight", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Pause" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Resume" })).toBeDisabled(); + }); + + it("uses the standard hover background treatment for the schedules link", () => { + render( + + + , + ); + + expect(screen.getByRole("link", { name: "View all schedules" })).toHaveClass( + "hover:bg-core-primary-5", + "px-3", + "py-2.5", + ); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/SchedulePopover.tsx b/client/src/protoFleet/components/PageHeader/SchedulePopover.tsx new file mode 100644 index 000000000..5ed053e3e --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/SchedulePopover.tsx @@ -0,0 +1,93 @@ +import { Link } from "react-router-dom"; +import clsx from "clsx"; + +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import { + formatSchedulePopoverRelativeStart, + getSchedulePopoverActionSummary, + getSchedulePopoverPowerTargetDetail, + getSchedulePopoverTargetSummary, + type SchedulePopoverSection, +} from "@/protoFleet/components/PageHeader/schedulePillUtils"; +import Button, { sizes, variants } from "@/shared/components/Button"; + +interface SchedulePopoverProps { + sections: SchedulePopoverSection[]; + pendingScheduleId: string | null; + onToggleScheduleStatus: (schedule: ScheduleListItem) => Promise; + onNavigateToSchedules: () => void; +} + +const SchedulePopover = ({ + sections, + pendingScheduleId, + onToggleScheduleStatus, + onNavigateToSchedules, +}: SchedulePopoverProps) => { + return ( +
+
+ {sections.map((section, sectionIndex) => ( +
0, + "pb-3": sectionIndex < sections.length - 1, + })} + > +
{section.title}
+ + {section.schedules.map((schedule) => { + const powerTargetDetail = getSchedulePopoverPowerTargetDetail(schedule); + const relativeStart = section.id === "active" ? formatSchedulePopoverRelativeStart(schedule) : null; + + return ( +
+
+
{schedule.name}
+ {relativeStart ? ( +
{relativeStart}
+ ) : null} +
+ {getSchedulePopoverActionSummary(section.id, schedule)} +
+
+ {getSchedulePopoverTargetSummary(schedule)} +
+ {powerTargetDetail ? ( +
{powerTargetDetail}
+ ) : null} +
+ +
+ ); + })} +
+ ))} +
+ +
+ + View all schedules + +
+
+ ); +}; + +export default SchedulePopover; diff --git a/client/src/protoFleet/components/PageHeader/index.ts b/client/src/protoFleet/components/PageHeader/index.ts new file mode 100644 index 000000000..b294687a4 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/index.ts @@ -0,0 +1,3 @@ +import PageHeader from "./PageHeader"; + +export default PageHeader; diff --git a/client/src/protoFleet/components/PageHeader/schedulePillUtils.ts b/client/src/protoFleet/components/PageHeader/schedulePillUtils.ts new file mode 100644 index 000000000..4143afc00 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/schedulePillUtils.ts @@ -0,0 +1,205 @@ +import { PowerTargetMode, ScheduleType } from "@/protoFleet/api/generated/schedule/v1/schedule_pb"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import { scheduleActionLabels } from "@/protoFleet/features/settings/components/Schedules/constants"; +import { + addDaysToDateValue, + buildDateInTimeZone, + formatTimeZoneDateParts, + getTimeZoneDateTimeParts, +} from "@/protoFleet/features/settings/utils/scheduleDateUtils"; + +const MINUTE_IN_MS = 60_000; +const HOUR_IN_MINUTES = 60; +const DAY_IN_MINUTES = 24 * HOUR_IN_MINUTES; +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", +}); +const nextRunDateTimeFormatter = new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +const schedulePopoverSectionConfigs = [ + { + id: "running", + title: "Active now", + status: "running", + }, + { + id: "active", + title: "Up next", + status: "active", + }, + { + id: "paused", + title: "Paused", + status: "paused", + }, +] as const satisfies readonly { + id: string; + title: string; + status: ScheduleListItem["status"]; +}[]; + +export type SchedulePopoverSectionId = (typeof schedulePopoverSectionConfigs)[number]["id"]; + +export interface SchedulePopoverSection { + id: SchedulePopoverSectionId; + title: string; + schedules: ScheduleListItem[]; +} + +const MAX_POPOVER_SCHEDULES = 3; + +const sortByPriority = (schedules: ScheduleListItem[]) => + [...schedules].sort((left, right) => left.priority - right.priority); + +const toDate = (seconds: bigint, nanos = 0) => new Date(Number(seconds) * 1000 + Math.floor(nanos / 1_000_000)); + +const shouldUseNextRunDate = (sectionId: SchedulePopoverSectionId) => sectionId === "active" || sectionId === "paused"; + +const formatActionTimeWindow = (schedule: ScheduleListItem, dateValue: string, startSummary: string) => { + if (schedule.action !== "setPowerTarget" || !schedule.rawSchedule.endTime) { + return null; + } + + const endDateValue = + schedule.rawSchedule.endTime < schedule.rawSchedule.startTime ? addDaysToDateValue(dateValue, 1) : dateValue; + const end = buildDateInTimeZone(endDateValue, schedule.rawSchedule.endTime, schedule.rawSchedule.timezone); + + if (!end) { + return null; + } + + return `${scheduleActionLabels[schedule.action]} · ${startSummary} – ${timeFormatter.format(end)}`; +}; + +export const formatSchedulePopoverRelativeStart = (schedule: ScheduleListItem) => { + const nextRunAt = schedule.rawSchedule.nextRunAt; + + if (!nextRunAt) { + return schedule.nextRunSummary ?? "Starting soon"; + } + + const nextRun = toDate(nextRunAt.seconds, nextRunAt.nanos); + const diffMinutes = Math.max(0, Math.floor((nextRun.getTime() - Date.now()) / MINUTE_IN_MS)); + + if (diffMinutes <= 0) { + return "Starting soon"; + } + + const days = Math.floor(diffMinutes / DAY_IN_MINUTES); + const hours = Math.floor((diffMinutes % DAY_IN_MINUTES) / HOUR_IN_MINUTES); + const minutes = diffMinutes % HOUR_IN_MINUTES; + const parts: string[] = []; + + if (days > 0) { + parts.push(`${days}d`); + } + + if (hours > 0 && parts.length < 2) { + parts.push(`${hours}h`); + } + + if (minutes > 0 && parts.length < 2) { + parts.push(`${minutes}m`); + } + + return `Starting in ${parts.join(" ")}`; +}; + +export const getSchedulePopoverActionSummary = (sectionId: SchedulePopoverSectionId, schedule: ScheduleListItem) => { + const { rawSchedule } = schedule; + const referenceDateValue = rawSchedule.startDate; + const start = buildDateInTimeZone(referenceDateValue, rawSchedule.startTime, rawSchedule.timezone); + + if (!start) { + return scheduleActionLabels[schedule.action]; + } + + if (shouldUseNextRunDate(sectionId) && rawSchedule.nextRunAt) { + const nextRun = toDate(rawSchedule.nextRunAt.seconds, rawSchedule.nextRunAt.nanos); + const nextRunParts = getTimeZoneDateTimeParts(nextRun, rawSchedule.timezone); + + if (nextRunParts) { + const nextRunDateValue = formatTimeZoneDateParts(nextRunParts); + const timeWindowSummary = formatActionTimeWindow( + schedule, + nextRunDateValue, + nextRunDateTimeFormatter.format(nextRun), + ); + + if (timeWindowSummary) { + return timeWindowSummary; + } + } + + return `${scheduleActionLabels[schedule.action]} · ${nextRunDateTimeFormatter.format(nextRun)}`; + } + + if (rawSchedule.scheduleType === ScheduleType.ONE_TIME) { + return `${scheduleActionLabels[schedule.action]} · ${dateTimeFormatter.format(start)}`; + } + + return ( + formatActionTimeWindow(schedule, referenceDateValue, timeFormatter.format(start)) ?? + `${scheduleActionLabels[schedule.action]} · ${timeFormatter.format(start)}` + ); +}; + +export const getSchedulePopoverPowerTargetDetail = (schedule: ScheduleListItem) => { + if (schedule.action !== "setPowerTarget") { + return null; + } + + switch (schedule.rawSchedule.actionConfig?.mode) { + case PowerTargetMode.MAX: + return "Max"; + case PowerTargetMode.DEFAULT: + return "Default"; + default: + return null; + } +}; + +export const getSchedulePopoverTargetSummary = (schedule: ScheduleListItem) => schedule.targetSummary; + +export const buildSchedulePopoverSections = (schedules: ScheduleListItem[]): SchedulePopoverSection[] => { + let remainingSlots = MAX_POPOVER_SCHEDULES; + + return schedulePopoverSectionConfigs.reduce((result, sectionConfig) => { + if (remainingSlots <= 0) { + return result; + } + + const sectionSchedules = sortByPriority(schedules.filter((schedule) => schedule.status === sectionConfig.status)); + + if (sectionSchedules.length === 0) { + return result; + } + + const visibleSchedules = sectionSchedules.slice(0, remainingSlots); + remainingSlots -= visibleSchedules.length; + + result.push({ + id: sectionConfig.id, + title: sectionConfig.title, + schedules: visibleSchedules, + }); + + return result; + }, []); +}; + +export const selectPillSchedule = (sections: SchedulePopoverSection[]) => sections[0]?.schedules[0] ?? null; diff --git a/client/src/protoFleet/components/PageHeader/useSchedulePillData.test.tsx b/client/src/protoFleet/components/PageHeader/useSchedulePillData.test.tsx new file mode 100644 index 000000000..8cf99d352 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/useSchedulePillData.test.tsx @@ -0,0 +1,68 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useSchedulePillData } from "./useSchedulePillData"; +import { useScheduleApiContext } from "@/protoFleet/api/ScheduleApiContext"; +import { SCHEDULES_CHANGED_EVENT } from "@/protoFleet/api/scheduleEvents"; + +vi.mock("@/protoFleet/api/ScheduleApiContext", () => ({ + useScheduleApiContext: vi.fn(), +})); + +vi.mock("@/shared/features/toaster", () => ({ + pushToast: vi.fn(), + STATUSES: { + error: "error", + }, +})); + +describe("useSchedulePillData", () => { + const refreshSchedules = vi.fn().mockResolvedValue([]); + + beforeEach(() => { + vi.useFakeTimers(); + refreshSchedules.mockClear(); + vi.mocked(useScheduleApiContext).mockReturnValue({ + schedules: [], + isLoading: false, + listSchedules: vi.fn().mockResolvedValue([]), + refreshSchedules, + createSchedule: vi.fn().mockResolvedValue(undefined), + updateSchedule: vi.fn().mockResolvedValue(undefined), + pauseSchedule: vi.fn(), + resumeSchedule: vi.fn(), + deleteSchedule: vi.fn().mockResolvedValue(undefined), + reorderSchedules: vi.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("refreshes immediately and on the polling interval", async () => { + renderHook(() => useSchedulePillData()); + + expect(refreshSchedules).toHaveBeenCalledTimes(1); + expect(refreshSchedules).toHaveBeenNthCalledWith(1, { background: true }); + + await act(async () => { + vi.advanceTimersByTime(30_000); + }); + + expect(refreshSchedules).toHaveBeenCalledTimes(2); + expect(refreshSchedules).toHaveBeenNthCalledWith(2, { background: true }); + }); + + it("does not refetch immediately for same-tab schedule mutation events", async () => { + renderHook(() => useSchedulePillData()); + + refreshSchedules.mockClear(); + + await act(async () => { + window.dispatchEvent(new CustomEvent(SCHEDULES_CHANGED_EVENT)); + }); + + expect(refreshSchedules).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/useSchedulePillData.ts b/client/src/protoFleet/components/PageHeader/useSchedulePillData.ts new file mode 100644 index 000000000..a3ab939d8 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/useSchedulePillData.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { buildSchedulePopoverSections, type SchedulePopoverSection, selectPillSchedule } from "./schedulePillUtils"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useScheduleApiContext } from "@/protoFleet/api/ScheduleApiContext"; +import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import { pushToast, STATUSES } from "@/shared/features/toaster"; + +export interface UseSchedulePillDataResult { + hasVisibleSchedules: boolean; + pillSchedule: ScheduleListItem | null; + sections: SchedulePopoverSection[]; + pendingScheduleId: string | null; + onToggleScheduleStatus: (schedule: ScheduleListItem) => Promise; +} + +const POLL_INTERVAL_MS = 30_000; + +export const useSchedulePillData = (): UseSchedulePillDataResult => { + const { schedules, refreshSchedules, pauseSchedule, resumeSchedule } = useScheduleApiContext(); + const [pendingScheduleId, setPendingScheduleId] = useState(null); + + useEffect(() => { + const refreshScheduleSummary = () => { + void refreshSchedules({ background: true }).catch(() => {}); + }; + + refreshScheduleSummary(); + const intervalId = window.setInterval(refreshScheduleSummary, POLL_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [refreshSchedules]); + + const { sections, pillSchedule } = useMemo(() => { + const nextSections = buildSchedulePopoverSections(schedules); + + return { + sections: nextSections, + pillSchedule: selectPillSchedule(nextSections), + }; + }, [schedules]); + + const onToggleScheduleStatus = useCallback( + async (schedule: ScheduleListItem) => { + if (schedule.status === "completed") { + return; + } + + setPendingScheduleId(schedule.id); + + try { + if (schedule.status === "paused") { + await resumeSchedule(schedule.id); + } else { + await pauseSchedule(schedule.id); + } + } catch (error) { + pushToast({ + message: getErrorMessage(error, "Failed to update schedule"), + status: STATUSES.error, + }); + } finally { + setPendingScheduleId((current) => (current === schedule.id ? null : current)); + } + }, + [pauseSchedule, resumeSchedule], + ); + + return useMemo( + () => ({ + hasVisibleSchedules: pillSchedule !== null, + pillSchedule, + sections, + pendingScheduleId, + onToggleScheduleStatus, + }), + [onToggleScheduleStatus, pendingScheduleId, pillSchedule, sections], + ); +}; diff --git a/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.stories.tsx b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.stories.tsx new file mode 100644 index 000000000..6fb8cfe19 --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.stories.tsx @@ -0,0 +1,25 @@ +import { ElementType } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { default as StoryComponent } from "."; +import { secondaryNavItems } from "@/protoFleet/config/navItems"; + +export const SecondaryNavigation = () => { + return ; +}; + +export default { + title: "Proto Fleet/SecondaryNavigation", + parameters: { + withRouter: false, + }, + args: {}, + argTypes: {}, + decorators: [ + (Story: ElementType) => ( + + + + ), + ], +}; diff --git a/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.test.tsx b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.test.tsx new file mode 100644 index 000000000..723647243 --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.test.tsx @@ -0,0 +1,50 @@ +import { MemoryRouter } from "react-router-dom"; +import { render, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import SecondaryNavigation from "./SecondaryNavigation"; +import { SecondaryNavItem } from "@/protoFleet/config/navItems"; + +describe("Secondary Navigation", () => { + const items: SecondaryNavItem[] = [ + { + path: "/bar/foo", + label: "Bar Foo", + parent: "/bar", + }, + { + path: "/bar/bar", + label: "Bar Bar", + parent: "/bar", + }, + { + path: "/bar/baz", + label: "Bar Baz", + parent: "/bar", + }, + ]; + + it("should render the correct number nav items", () => { + const { getByTestId } = render( + + + , + ); + + const navMenu = getByTestId("secondary-nav"); + const navItems = navMenu.querySelectorAll("li"); + expect(navItems.length).toBe(3); + }); + + it("should show the correct active nav item", async () => { + const { getByText } = render( + + + , + ); + + const currentItem = getByText("Bar Foo"); + await waitFor(() => { + expect(currentItem).toHaveClass("bg-core-primary-5"); + }); + }); +}); diff --git a/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.tsx b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.tsx new file mode 100644 index 000000000..06a84631a --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/SecondaryNavigation.tsx @@ -0,0 +1,66 @@ +import { Link, useLocation } from "react-router-dom"; +import { clsx } from "clsx"; + +import { type SecondaryNavItem } from "@/protoFleet/config/navItems"; +import { useRole } from "@/protoFleet/store"; +import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; +import { stripLeadingSlash } from "@/shared/utils/stringUtils"; + +type SecondaryNavigationProps = { + items: SecondaryNavItem[]; +}; + +const SecondaryNavigation = ({ items }: SecondaryNavigationProps) => { + const { pathname } = useLocation(); + const { isPhone, isTablet } = useWindowDimensions(); + const currentRole = useRole(); + + // Hide on mobile and tablet since secondary nav items are shown in main menu + if (isPhone || isTablet) return null; + + // Filter items to only show those whose parent matches the current path and whose role matches + const visibleItems = items.filter((item) => { + const _pathname = stripLeadingSlash(pathname); + const _parent = stripLeadingSlash(item.parent); + const pathMatch = _pathname === _parent || _pathname.startsWith(`${_parent}/`); + const roleMatch = !item.allowedRoles || item.allowedRoles.includes(currentRole); + return pathMatch && roleMatch; + }); + + const isCurrentPath = (path: string) => { + const _pathname = stripLeadingSlash(pathname); + const _path = stripLeadingSlash(path); + return _pathname === _path || _pathname.startsWith(`${_path}/`); + }; + + // if current route has no secondary nav items + // dont render anything + if (visibleItems.length === 0) return null; + + return ( + + ); +}; + +export default SecondaryNavigation; diff --git a/client/src/protoFleet/components/SecondaryNavigation/index.ts b/client/src/protoFleet/components/SecondaryNavigation/index.ts new file mode 100644 index 000000000..ad89763d6 --- /dev/null +++ b/client/src/protoFleet/components/SecondaryNavigation/index.ts @@ -0,0 +1,3 @@ +import SecondaryNavigation from "./SecondaryNavigation"; + +export default SecondaryNavigation; diff --git a/client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx b/client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx new file mode 100644 index 000000000..c21f9ed76 --- /dev/null +++ b/client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from "react"; +import { Link, useParams } from "react-router-dom"; +import { MinerHostingProvider } from "@/protoOS/contexts/MinerHostingContext"; +import { DismissCircleDark } from "@/shared/assets/icons"; + +const CloseButton = ({ id }: { id: string }) => { + return ( + + + {id} + + ); +}; + +/** Encode the route param as a single safe path segment. Strips C0 control + * characters and whitespace, then re-encodes so /, \, .., ?, # etc. are + * never interpreted as URL structure when used in baseUrl or minerRoot. */ +// eslint-disable-next-line no-control-regex +const safePathSegment = (raw: string): string => encodeURIComponent(raw.replace(/[\x00-\x1f\x7f]/g, "")); + +const SingleMinerWrapper = ({ children }: { children: ReactNode }) => { + const { id: rawId } = useParams(); + const safeId = safePathSegment(rawId || ""); + const displayId = rawId || ""; + + // Here we are just setting the base url to /:id, + // which vite proxies to the actual miner api server. + // If we wanted to make this request to ProtoFleet backend we + // could pass /miners/:id instead + return ( + ) as ReactNode} + > + {children} + + ); +}; + +export default SingleMinerWrapper; diff --git a/client/src/protoFleet/components/SingleMinerWrapper/index.ts b/client/src/protoFleet/components/SingleMinerWrapper/index.ts new file mode 100644 index 000000000..298e4833a --- /dev/null +++ b/client/src/protoFleet/components/SingleMinerWrapper/index.ts @@ -0,0 +1,3 @@ +import SingleMinerWrapper from "./SingleMinerWrapper"; + +export default SingleMinerWrapper; diff --git a/client/src/protoFleet/components/StatusModal/StatusModal.tsx b/client/src/protoFleet/components/StatusModal/StatusModal.tsx new file mode 100644 index 000000000..bddb62798 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/StatusModal.tsx @@ -0,0 +1,272 @@ +import { useCallback, useMemo, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import type { ComponentAddress, ProtoFleetStatusModalProps } from "./types"; +import { + buildComponentStatusProps, + getComponentTitle, + mapErrorComponentTypeToShared, + transformErrorsForModal, + transformFleetErrorsToShared, +} from "./utils"; +import { ComponentType as ErrorComponentType, type ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { StartMiningRequestSchema } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useDeviceErrors } from "@/protoFleet/api/useDeviceErrors"; +import { useMinerCommand } from "@/protoFleet/api/useMinerCommand"; +import { createDeviceSelector } from "@/protoFleet/features/fleetManagement/utils/deviceSelector"; + +import { variants } from "@/shared/components/Button"; +import { StatusModal as SharedStatusModal } from "@/shared/components/StatusModal"; +import type { ComponentStatusData, MinerStatusData } from "@/shared/components/StatusModal/types"; +import { pushToast, STATUSES as TOAST_STATUSES, updateToast } from "@/shared/features/toaster"; +import { useMinerStatusSummary } from "@/shared/hooks/useStatusSummary"; + +// Stable empty array to avoid triggering useDeviceErrors internal effects on every render +const EMPTY_DEVICE_IDS: string[] = []; + +/** + * ProtoFleet-specific StatusModal wrapper that integrates with the store + * + * This component encapsulates all the integration logic between the ProtoFleet store + * and the shared StatusModal component. It handles: + * - Store data fetching and transformation + * - Component navigation state + * - Error grouping and formatting + * + * @example + * ```tsx + * const [isModalOpen, setModalOpen] = useState(false); + * + * setModalOpen(false)} + * deviceId={minerId} + * /> + * ``` + */ +const ProtoFleetStatusModal = ({ + open, + onClose, + deviceId, + miner, + componentAddress, + showBackButton = true, +}: ProtoFleetStatusModalProps) => { + const isVisible = open ?? true; + + // Component navigation state + const [component, setComponent] = useState(componentAddress); + + // Fetch errors for this device when modal is visible + const modalDeviceIds = useMemo(() => (isVisible && deviceId ? [deviceId] : EMPTY_DEVICE_IDS), [isVisible, deviceId]); + const { errorsByDevice } = useDeviceErrors(modalDeviceIds); + + const handleClose = useCallback(() => { + setComponent(componentAddress); + onClose(); + }, [componentAddress, onClose]); + + // Derive errors from the local fetch (not the store) + const allErrors = useMemo(() => (deviceId ? (errorsByDevice[deviceId] ?? []) : []), [errorsByDevice, deviceId]); + const groupedErrors = useMemo(() => { + const grouped = { + hashboard: [] as ErrorMessage[], + psu: [] as ErrorMessage[], + fan: [] as ErrorMessage[], + controlBoard: [] as ErrorMessage[], + other: [] as ErrorMessage[], + }; + allErrors.forEach((error) => { + switch (error.componentType) { + case ErrorComponentType.HASH_BOARD: + grouped.hashboard.push(error); + break; + case ErrorComponentType.PSU: + grouped.psu.push(error); + break; + case ErrorComponentType.FAN: + grouped.fan.push(error); + break; + case ErrorComponentType.CONTROL_BOARD: + grouped.controlBoard.push(error); + break; + default: + grouped.other.push(error); + break; + } + }); + return grouped; + }, [allErrors]); + + // Wake miner functionality + const { startMining } = useMinerCommand(); + + const handleWakeMiner = useCallback(() => { + if (!deviceId) return; + + const toastId = pushToast({ + message: "Waking miner...", + status: TOAST_STATUSES.loading, + longRunning: true, + }); + + const deviceSelector = createDeviceSelector("subset", [deviceId]); + const startMiningRequest = create(StartMiningRequestSchema, { + deviceSelector, + }); + + startMining({ + startMiningRequest, + onSuccess: () => { + updateToast(toastId, { + message: "Miner is waking up", + status: TOAST_STATUSES.success, + }); + onClose(); + }, + onError: (error) => { + updateToast(toastId, { + message: `Failed to wake miner: ${error}`, + status: TOAST_STATUSES.error, + }); + }, + }); + }, [deviceId, startMining, onClose]); + + // Transform ProtoFleet errors to shared format for status computation + const sharedErrors = useMemo(() => transformFleetErrorsToShared(groupedErrors), [groupedErrors]); + + // Determine status flags from DeviceStatus and PairingStatus + const needsAuthentication = miner?.pairingStatus === PairingStatus.AUTHENTICATION_NEEDED; + const isOffline = miner?.deviceStatus === DeviceStatus.OFFLINE; + // When authentication is needed, we can't trust INACTIVE (or MAINTENANCE) status + // (could be sleeping OR showing as inactive/maintenance because we can't authenticate) + const isSleeping = + (miner?.deviceStatus === DeviceStatus.INACTIVE || miner?.deviceStatus === DeviceStatus.MAINTENANCE) && + !needsAuthentication; + const needsMiningPool = miner?.deviceStatus === DeviceStatus.NEEDS_MINING_POOL; + + // Compute summary using shared hook (replaces API-provided summary) + const summary = useMinerStatusSummary(sharedErrors, isSleeping, isOffline, needsAuthentication, needsMiningPool); + + // getMinerStatus function - returns complete data including config + const getMinerStatus = useCallback((): MinerStatusData => { + // Create onClick handler that navigates to component details + const onClickHandler = (deviceId: string, type: ErrorComponentType, componentId: string) => { + setComponent({ deviceId, componentType: type, componentId }); + }; + + // Transform grouped errors with click handlers + const errorsBySource = { + hashboard: transformErrorsForModal(groupedErrors.hashboard || [], deviceId, onClickHandler), + psu: transformErrorsForModal(groupedErrors.psu || [], deviceId, onClickHandler), + fan: transformErrorsForModal(groupedErrors.fan || [], deviceId, onClickHandler), + controlBoard: transformErrorsForModal(groupedErrors.controlBoard || [], deviceId, onClickHandler), + other: transformErrorsForModal(groupedErrors.other || [], deviceId, onClickHandler), + }; + + // Check if miner is sleeping (offline state in fleet context) + // Don't show wake button if authentication is needed (can't trust INACTIVE/MAINTENANCE status) + const isMinersleeping = + (miner?.deviceStatus === DeviceStatus.INACTIVE || miner?.deviceStatus === DeviceStatus.MAINTENANCE) && + !needsAuthentication; + + // Build buttons + const buttons = []; + + // Add wake miner button if miner is sleeping + if (isMinersleeping) { + buttons.push({ + text: "Wake miner", + variant: variants.secondary, + onClick: () => { + handleClose(); + handleWakeMiner(); + }, + }); + } + + buttons.push({ + text: "Done", + variant: variants.primary, + onClick: handleClose, + }); + + return { + props: { + title: summary.title, + subtitle: summary.subtitle, + errors: errorsBySource, + isSleeping: isMinersleeping, + isOffline, + needsAuthentication, + needsMiningPool, + }, + title: `${miner?.name || deviceId} status`, + buttons, + onDismiss: handleClose, + }; + }, [ + groupedErrors, + summary, + miner, + deviceId, + handleWakeMiner, + handleClose, + isOffline, + needsAuthentication, + needsMiningPool, + ]); + + // getComponentStatus function - returns complete data including config + const getComponentStatus = useCallback( + (address: ComponentAddress): ComponentStatusData | undefined => { + const { componentType: type, componentId: id } = address; + + // Build component status props using the miner data and errors + const props = buildComponentStatusProps(miner, type, id, allErrors); + + if (!props) { + // Return undefined if component not found + return undefined; + } + + const sharedType = mapErrorComponentTypeToShared(type); + if (!sharedType) return undefined; + + return { + props, + title: getComponentTitle(sharedType), + buttons: [ + { + text: "Done", + variant: variants.primary, + onClick: handleClose, + }, + ], + onDismiss: handleClose, + onNavigateBack: () => setComponent(undefined), + }; + }, + [miner, handleClose, allErrors], + ); + + // Don't render if no miner data + if (!miner) { + return null; + } + + // Render the shared StatusModal with integration data + return ( + + ); +}; + +export default ProtoFleetStatusModal; diff --git a/client/src/protoFleet/components/StatusModal/constants.ts b/client/src/protoFleet/components/StatusModal/constants.ts new file mode 100644 index 000000000..b738770d0 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/constants.ts @@ -0,0 +1,59 @@ +import { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { ComponentType } from "@/shared/components/StatusModal/types"; + +/** + * Mapping from error API component types to shared component types + * Only includes supported component types - unsupported types will return undefined + */ +export const ERROR_COMPONENT_TO_SHARED: Partial> = { + [ErrorComponentType.HASH_BOARD]: "hashboard", + [ErrorComponentType.PSU]: "psu", + [ErrorComponentType.FAN]: "fan", + [ErrorComponentType.CONTROL_BOARD]: "controlBoard", +}; + +/** + * Mapping from shared component types to error API component types + */ +export const SHARED_TO_ERROR_COMPONENT: Record = { + hashboard: ErrorComponentType.HASH_BOARD, + psu: ErrorComponentType.PSU, + fan: ErrorComponentType.FAN, + controlBoard: ErrorComponentType.CONTROL_BOARD, + other: ErrorComponentType.UNSPECIFIED, +}; + +/** + * Component display titles + */ +export const COMPONENT_TITLES: Record = { + fan: "Fan status", + hashboard: "Hashboard status", + psu: "PSU status", + controlBoard: "Control board status", + other: "Needs attention", +}; + +/** + * Component names (without "status") + */ +export const COMPONENT_NAMES: Record = { + fan: "Fan", + hashboard: "Hashboard", + psu: "PSU", + controlBoard: "Control board", + other: "Needs attention", +}; + +/** + * Set of component types that are supported in the UI + * Components not in this set (like EEPROM, IO_MODULE) will be ignored since they are not yet accommodated in the UI + * + * TODO: Add support for these component types in the UI + */ +export const SUPPORTED_COMPONENT_TYPES = new Set([ + ErrorComponentType.HASH_BOARD, + ErrorComponentType.PSU, + ErrorComponentType.FAN, + ErrorComponentType.CONTROL_BOARD, +]); diff --git a/client/src/protoFleet/components/StatusModal/hooks/index.ts b/client/src/protoFleet/components/StatusModal/hooks/index.ts new file mode 100644 index 000000000..a272ba329 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/hooks/index.ts @@ -0,0 +1,2 @@ +// Re-export StatusModal-specific types +export type { ComponentHardware } from "./useStatusModalHooks"; diff --git a/client/src/protoFleet/components/StatusModal/hooks/useStatusModalHooks.ts b/client/src/protoFleet/components/StatusModal/hooks/useStatusModalHooks.ts new file mode 100644 index 000000000..9ead3d60b --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/hooks/useStatusModalHooks.ts @@ -0,0 +1,5 @@ +export type ComponentHardware = { + model?: string; + serialNumber?: string; + firmwareVersion?: string; +}; diff --git a/client/src/protoFleet/components/StatusModal/index.ts b/client/src/protoFleet/components/StatusModal/index.ts new file mode 100644 index 000000000..64814ae62 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/index.ts @@ -0,0 +1,2 @@ +export { default as ProtoFleetStatusModal } from "./StatusModal"; +export type { ProtoFleetStatusModalProps, ComponentAddress } from "./types"; diff --git a/client/src/protoFleet/components/StatusModal/types.ts b/client/src/protoFleet/components/StatusModal/types.ts new file mode 100644 index 000000000..518777bb3 --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/types.ts @@ -0,0 +1,44 @@ +/** + * ProtoFleet-specific StatusModal types + */ + +import type { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +/** + * Component address for navigation to ComponentStatusModal + * In ProtoFleet, we use the component type from the errors API + * deviceId is included to ensure uniqueness across devices + * componentId is the ID from the API (currently index as string, will be unique ID in future) + */ +export interface ComponentAddress { + deviceId: string; + componentType: ErrorComponentType; + componentId: string; // Component ID from API (for RESULT_VIEW_COMPONENT calls) +} + +/** + * Props for the ProtoFleet StatusModal wrapper component + * + * This wrapper encapsulates all integration logic with the ProtoFleet store + * and provides a simple API for consumers. + */ +export interface ProtoFleetStatusModalProps { + /** Controls modal visibility */ + open?: boolean; + + /** Callback when modal should be closed */ + onClose: () => void; + + /** The device identifier (miner ID) to show status for */ + deviceId: string; + + /** Optional miner data — if not provided, status info will be limited */ + miner?: MinerStateSnapshot; + + /** Optional initial component to display (defaults to miner view) */ + componentAddress?: ComponentAddress; + + /** Whether to show back button in component views (defaults to true) */ + showBackButton?: boolean; +} diff --git a/client/src/protoFleet/components/StatusModal/utils.ts b/client/src/protoFleet/components/StatusModal/utils.ts new file mode 100644 index 000000000..83e8dec9e --- /dev/null +++ b/client/src/protoFleet/components/StatusModal/utils.ts @@ -0,0 +1,238 @@ +import { timestampMs } from "@bufbuild/protobuf/wkt"; +import { + COMPONENT_NAMES, + COMPONENT_TITLES, + ERROR_COMPONENT_TO_SHARED, + SHARED_TO_ERROR_COMPONENT, + SUPPORTED_COMPONENT_TYPES, +} from "./constants"; +import { ComponentType as ErrorComponentType } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { ErrorMessage } from "@/protoFleet/api/generated/errors/v1/errors_pb"; +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import type { + ComponentMetadata, + ComponentMetric, + ComponentStatusModalProps, + ComponentType, + ErrorData, +} from "@/shared/components/StatusModal/types"; +import { + computeComponentStatusTitle, + type GroupedStatusErrors, + type StatusComponentType, +} from "@/shared/hooks/useStatusSummary"; + +/** + * Get the display title for a component type + */ +export const getComponentTitle = (type: ComponentType): string => { + return COMPONENT_TITLES[type]; +}; + +/** + * Get the component name (without "status") + */ +export const getComponentName = (type: ComponentType): string => { + return COMPONENT_NAMES[type]; +}; + +/** + * Maps error API component type to shared component type + */ +export function mapErrorComponentTypeToShared(type: ErrorComponentType): ComponentType | null { + return ERROR_COMPONENT_TO_SHARED[type] ?? null; +} + +/** + * Maps shared component type to error API component type + */ +export function mapSharedToErrorComponentType(type: ComponentType): ErrorComponentType { + return SHARED_TO_ERROR_COMPONENT[type]; +} + +/** + * Type for grouped fleet errors returned by useGroupedErrors hook + */ +export type GroupedFleetErrors = { + hashboard: ErrorMessage[]; + psu: ErrorMessage[]; + fan: ErrorMessage[]; + controlBoard: ErrorMessage[]; + other: ErrorMessage[]; +}; + +/** + * Transform ProtoFleet grouped errors to shared format for status computation + */ +export function transformFleetErrorsToShared(groupedErrors: GroupedFleetErrors): GroupedStatusErrors { + const transformErrors = (errors: ErrorMessage[], componentType: StatusComponentType) => + errors.map((e) => { + // For "other" errors, don't include slot - componentId may be a pool index or other identifier + if (componentType === "other") { + return { componentType, slot: undefined }; + } + const parsed = e.componentId ? parseInt(e.componentId, 10) : NaN; + // componentId is already 1-based slot from firmware + return { + componentType, + slot: !isNaN(parsed) ? parsed : undefined, + }; + }); + + return { + hashboard: transformErrors(groupedErrors.hashboard, "hashboard"), + psu: transformErrors(groupedErrors.psu, "psu"), + fan: transformErrors(groupedErrors.fan, "fan"), + controlBoard: transformErrors(groupedErrors.controlBoard, "controlBoard"), + other: transformErrors(groupedErrors.other, "other"), + }; +} + +/** + * Get display index from component ID for UI display purposes only + * ComponentId contains 1-based slot values from firmware ("1", "2", "3") + * This will change when componentId becomes a unique ID + */ +export function getComponentDisplayIndex(componentId: string): number | null { + // componentId is already 1-based slot from firmware, use as-is for display + const slot = parseInt(componentId, 10); + return isNaN(slot) ? null : slot; +} + +/** + * Transforms errors array to ErrorData format for shared/components/StatusModal + * Groups errors by component and creates ErrorData objects + * Only creates onClick handlers when componentId exists + */ +export function transformErrorsForModal( + errors: ErrorMessage[], + deviceId: string, + onClick?: (deviceId: string, type: ErrorComponentType, componentId: string) => void, +): ErrorData[] { + const result: ErrorData[] = []; + + errors.forEach((error) => { + let componentName = "Unknown Component"; + let componentClickHandler: (() => void) | undefined; + + // Check if error has a supported componentType + if (error.componentType && SUPPORTED_COMPONENT_TYPES.has(error.componentType)) { + const sharedType = mapErrorComponentTypeToShared(error.componentType); + + if (sharedType) { + // Check if we have componentId for display and onClick + if (error.componentId) { + const componentIdValue = error.componentId; // Capture value for closure + const displayIndex = getComponentDisplayIndex(componentIdValue); + + componentName = displayIndex + ? `${getComponentName(sharedType)} ${displayIndex}` + : getComponentName(sharedType); + + // Create onClick handler with componentId + if (onClick) { + componentClickHandler = () => onClick(deviceId, error.componentType, componentIdValue); + } + } else { + // No componentId - just show component type without index + componentName = getComponentName(sharedType); + // No onClick handler since we can't navigate without componentId + } + } + } else { + // Handle unsupported or missing component types as "other" + componentName = getComponentName("other"); + } + + // Handle timestamp conversion - convert to seconds for shared formatters + let timestamp: number | undefined; + if (error.lastSeenAt) { + timestamp = Math.floor(timestampMs(error.lastSeenAt) / 1000); + } + + // Create ErrorData object with the expected structure + result.push({ + componentName, + message: error.summary || "Unknown error", + timestamp, + onClick: componentClickHandler, + }); + }); + + return result; +} + +/** + * Build component status props from fleet data + */ +export function buildComponentStatusProps( + miner: MinerStateSnapshot | undefined, + componentType: ErrorComponentType, + componentId: string, + allErrors?: ErrorMessage[], // Pass errors from normalized store +): ComponentStatusModalProps | undefined { + if (!miner) return undefined; + + const sharedType = mapErrorComponentTypeToShared(componentType); + if (!sharedType) return undefined; + + // Get display index for UI + const displayIndex = getComponentDisplayIndex(componentId); + + // Get component-specific errors (only for supported component types) + const componentErrors = + allErrors?.filter((error) => { + return ( + error.componentType === componentType && + error.componentId === componentId && + SUPPORTED_COMPONENT_TYPES.has(componentType) + ); + }) || []; + + // Transform errors to the shared format + const errors: ErrorData[] = componentErrors.map((error) => { + // Handle timestamp conversion - convert to seconds for shared formatters + let timestamp: number | undefined; + if (error.lastSeenAt) { + timestamp = Math.floor(timestampMs(error.lastSeenAt) / 1000); + } + + return { + componentName: `${getComponentName(sharedType)} ${displayIndex}`, + message: error.summary || "Unknown error", + timestamp, + }; + }); + + // No component-level telemetry metrics available + // TODO: Backend only collects miner-level aggregated telemetry + const telemetry: ComponentMetric[] = []; + + // Build metadata + const metadata: ComponentMetadata = { + component: { + label: "Component", + value: `${getComponentName(sharedType)} ${displayIndex}`, + }, + device: { + label: "Device", + value: miner.name || miner.deviceIdentifier, + }, + }; + + if (miner.model) { + metadata.model = { label: "Model", value: miner.model }; + } + + // Compute summary using shared logic + const summary = + computeComponentStatusTitle(sharedType, displayIndex ?? undefined, componentErrors.length) ?? undefined; + + return { + componentType: sharedType, + summary, + metrics: telemetry, + errors, + metadata, + }; +} diff --git a/client/src/protoFleet/config/navItems.ts b/client/src/protoFleet/config/navItems.ts new file mode 100644 index 000000000..1d8165aab --- /dev/null +++ b/client/src/protoFleet/config/navItems.ts @@ -0,0 +1,90 @@ +import { type ReactNode } from "react"; + +import { Activity, Fleet, Groups, Home, IconProps, Racks, Settings } from "@/shared/assets/icons"; + +export interface NavItem { + path: string; + label: string; + icon?: (i: IconProps) => ReactNode; +} + +export interface SecondaryNavItem { + path: string; + label: string; + parent: string; + allowedRoles?: string[]; +} + +// Primary navigation items (shown in main nav menu) +export const primaryNavItems: NavItem[] = [ + { + path: "/", + label: "Home", + icon: Home, + }, + { + path: "/miners", + label: "Miners", + icon: Fleet, + }, + { + path: "/groups", + label: "Groups", + icon: Groups, + }, + { + path: "/racks", + label: "Racks", + icon: Racks, + }, + { + path: "/activity", + label: "Activity", + icon: Activity, + }, + { + path: "/settings", + label: "Settings", + icon: Settings, + }, +]; + +// Secondary navigation items (shown in settings submenu) +export const secondaryNavItems: SecondaryNavItem[] = [ + { + path: "/settings/general", + label: "General", + parent: "/settings", + }, + { + path: "/settings/security", + label: "Security", + parent: "/settings", + }, + { + path: "/settings/team", + label: "Team", + parent: "/settings", + }, + { + path: "/settings/mining-pools", + label: "Pools", + parent: "/settings", + }, + { + path: "/settings/firmware", + label: "Firmware", + parent: "/settings", + }, + { + path: "/settings/schedules", + label: "Schedules", + parent: "/settings", + }, + { + path: "/settings/api-keys", + label: "API Keys", + parent: "/settings", + allowedRoles: ["SUPER_ADMIN", "ADMIN"], + }, +]; diff --git a/client/src/protoFleet/constants/polling.ts b/client/src/protoFleet/constants/polling.ts new file mode 100644 index 000000000..5e16a0c78 --- /dev/null +++ b/client/src/protoFleet/constants/polling.ts @@ -0,0 +1,6 @@ +/** + * Shared polling interval for refreshing data across all ProtoFleet pages. + * Can be overridden via VITE_POLL_INTERVAL_MS environment variable. + * Default: 60 seconds + */ +export const POLL_INTERVAL_MS = Number(import.meta.env.VITE_POLL_INTERVAL_MS) || 60000; diff --git a/client/src/protoFleet/features/activity/components/ActivityFilters.tsx b/client/src/protoFleet/features/activity/components/ActivityFilters.tsx new file mode 100644 index 000000000..b6f494c7d --- /dev/null +++ b/client/src/protoFleet/features/activity/components/ActivityFilters.tsx @@ -0,0 +1,97 @@ +import { useCallback, useMemo } from "react"; + +import type { EventTypeOption, UserOption } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { formatLabel } from "@/protoFleet/features/activity/utils/formatLabel"; +import Input from "@/shared/components/Input"; +import DropdownFilter from "@/shared/components/List/Filters/DropdownFilter"; + +interface ActivityFiltersProps { + searchValue: string; + onSearchChange: (value: string) => void; + eventTypes: EventTypeOption[]; + scopeTypes: string[]; + users: UserOption[]; + selectedTypes: string[]; + selectedScopes: string[]; + selectedUsers: string[]; + onTypesChange: (types: string[]) => void; + onScopesChange: (scopes: string[]) => void; + onUsersChange: (users: string[]) => void; +} + +const ActivityFilters = ({ + searchValue, + onSearchChange, + eventTypes, + scopeTypes, + users, + selectedTypes, + selectedScopes, + selectedUsers, + onTypesChange, + onScopesChange, + onUsersChange, +}: ActivityFiltersProps) => { + const typeOptions = useMemo( + () => eventTypes.map((et) => ({ id: et.eventType, label: formatLabel(et.eventType) })), + [eventTypes], + ); + + const scopeOptions = useMemo(() => scopeTypes.map((st) => ({ id: st, label: formatLabel(st) })), [scopeTypes]); + + const userOptions = useMemo(() => users.map((u) => ({ id: u.userId, label: u.username })), [users]); + + const handleClearSearch = useCallback( + (key: string) => { + if (key === "Escape") { + onSearchChange(""); + } + }, + [onSearchChange], + ); + + return ( +
+
+ onSearchChange(value)} + onKeyDown={handleClearSearch} + /> +
+ {typeOptions.length > 0 && ( + + )} + {scopeOptions.length > 0 && ( + + )} + {userOptions.length > 0 && ( + + )} +
+ ); +}; + +export default ActivityFilters; diff --git a/client/src/protoFleet/features/activity/components/ActivityTable.tsx b/client/src/protoFleet/features/activity/components/ActivityTable.tsx new file mode 100644 index 000000000..e27b5abaf --- /dev/null +++ b/client/src/protoFleet/features/activity/components/ActivityTable.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from "react"; +import clsx from "clsx"; + +import type { ActivityEntry } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { getActivityIcon } from "@/protoFleet/features/activity/utils/activityIcons"; +import { formatScope } from "@/protoFleet/features/activity/utils/formatScope"; +import { Alert } from "@/shared/assets/icons"; +import List from "@/shared/components/List"; +import type { ColConfig, ColTitles } from "@/shared/components/List/types"; +import { formatActivityTimestamp } from "@/shared/utils/formatTimestamp"; + +type ActivityColumns = "type" | "scope" | "user" | "timestamp"; + +const colTitles: ColTitles = { + type: "Type", + scope: "Scope", + user: "User", + timestamp: "Timestamp", +}; + +const activeCols: ActivityColumns[] = ["type", "scope", "user", "timestamp"]; + +const defaultNoDataElement =
No activity to display.
; + +interface ActivityTableProps { + activities: ActivityEntry[]; + totalCount: number; + noDataElement?: React.ReactNode; +} + +const ActivityTable = ({ activities, totalCount, noDataElement }: ActivityTableProps) => { + const colConfig: ColConfig = useMemo( + () => ({ + type: { + component: (entry) => { + const isFailed = entry.result === "failure"; + const Icon = isFailed ? Alert : getActivityIcon(entry.eventType); + return ( +
+
+ +
+ {entry.description} + {isFailed && Failed} +
+ ); + }, + width: "min-w-80", + allowWrap: true, + }, + scope: { + component: (entry) => ( + {formatScope(entry.scopeType, entry.scopeLabel, entry.scopeCount || undefined)} + ), + width: "w-48", + }, + user: { + component: (entry) => {entry.username ?? "\u2014"}, + width: "w-40", + }, + timestamp: { + component: (entry) => {formatActivityTimestamp(Number(entry.createdAt?.seconds))}, + width: "w-40", + }, + }), + [], + ); + + return ( + + items={activities} + itemKey="eventId" + activeCols={activeCols} + colTitles={colTitles} + colConfig={colConfig} + total={totalCount} + stickyFirstColumn={false} + itemName={{ singular: "activity", plural: "activities" }} + noDataElement={noDataElement ?? defaultNoDataElement} + /> + ); +}; + +export default ActivityTable; diff --git a/client/src/protoFleet/features/activity/index.ts b/client/src/protoFleet/features/activity/index.ts new file mode 100644 index 000000000..54068e579 --- /dev/null +++ b/client/src/protoFleet/features/activity/index.ts @@ -0,0 +1 @@ +export { default as ActivityPage } from "./pages/ActivityPage"; diff --git a/client/src/protoFleet/features/activity/pages/ActivityPage.tsx b/client/src/protoFleet/features/activity/pages/ActivityPage.tsx new file mode 100644 index 000000000..fd764aff0 --- /dev/null +++ b/client/src/protoFleet/features/activity/pages/ActivityPage.tsx @@ -0,0 +1,203 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; + +import { ActivityFilterSchema } from "@/protoFleet/api/generated/activity/v1/activity_pb"; +import { useActivity } from "@/protoFleet/api/useActivity"; +import { useActivityFilterOptions } from "@/protoFleet/api/useActivityFilterOptions"; +import { useExportActivity } from "@/protoFleet/api/useExportActivity"; +import NoFilterResultsEmptyState from "@/protoFleet/components/NoFilterResultsEmptyState"; +import ActivityFilters from "@/protoFleet/features/activity/components/ActivityFilters"; +import ActivityTable from "@/protoFleet/features/activity/components/ActivityTable"; +import { formatLabel } from "@/protoFleet/features/activity/utils/formatLabel"; +import { Alert, DismissTiny } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import Header from "@/shared/components/Header"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { debounce } from "@/shared/utils/utility"; + +const PAGE_SIZE = 50; + +const ActivityPage = () => { + const [searchText, setSearchText] = useState(""); + const [debouncedSearchText, setDebouncedSearchText] = useState(""); + const [selectedTypes, setSelectedTypes] = useState([]); + const [selectedScopes, setSelectedScopes] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + + const debouncedSetSearch = useMemo(() => debounce((text: string) => setDebouncedSearchText(text), 300), []); + useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchText(value); + if (value === "") { + debouncedSetSearch.cancel(); + setDebouncedSearchText(""); + } else { + debouncedSetSearch(value); + } + }, + [debouncedSetSearch], + ); + + const filter = useMemo( + () => + create(ActivityFilterSchema, { + eventTypes: selectedTypes, + scopeTypes: selectedScopes, + userIds: selectedUsers, + searchText: debouncedSearchText, + }), + [selectedTypes, selectedScopes, selectedUsers, debouncedSearchText], + ); + + const { activities, totalCount, isLoading, error, hasMore, loadMore } = useActivity({ + filter, + pageSize: PAGE_SIZE, + }); + const { exportCsv, isExportingCsv } = useExportActivity(); + const { eventTypes, scopeTypes, users } = useActivityFilterOptions(); + + const [hasLoaded, setHasLoaded] = useState(false); + const hasStartedLoadingRef = useRef(false); + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (isLoading) { + hasStartedLoadingRef.current = true; + } else if (hasStartedLoadingRef.current && !hasLoaded) { + setHasLoaded(true); + } + }, [isLoading, hasLoaded]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const isInitialLoad = isLoading && activities.length === 0 && !hasLoaded; + const isLoadingMore = isLoading && activities.length > 0; + + const hasActiveFilters = + selectedTypes.length > 0 || selectedScopes.length > 0 || selectedUsers.length > 0 || debouncedSearchText !== ""; + + const handleClearFilters = useCallback(() => { + setSearchText(""); + setDebouncedSearchText(""); + debouncedSetSearch.cancel(); + setSelectedTypes([]); + setSelectedScopes([]); + setSelectedUsers([]); + }, [debouncedSetSearch]); + + const handleRemoveType = useCallback((id: string) => setSelectedTypes((prev) => prev.filter((t) => t !== id)), []); + + const handleRemoveScope = useCallback((id: string) => setSelectedScopes((prev) => prev.filter((s) => s !== id)), []); + + const handleRemoveUser = useCallback((id: string) => setSelectedUsers((prev) => prev.filter((u) => u !== id)), []); + + const activeFilterPills = useMemo(() => { + const pills: { key: string; label: string; onRemove: () => void }[] = []; + for (const id of selectedTypes) { + pills.push({ key: `type-${id}`, label: formatLabel(id), onRemove: () => handleRemoveType(id) }); + } + for (const id of selectedScopes) { + pills.push({ key: `scope-${id}`, label: formatLabel(id), onRemove: () => handleRemoveScope(id) }); + } + for (const id of selectedUsers) { + const user = users.find((u) => u.userId === id); + pills.push({ + key: `user-${id}`, + label: user?.username ?? id, + onRemove: () => handleRemoveUser(id), + }); + } + return pills; + }, [selectedTypes, selectedScopes, selectedUsers, users, handleRemoveType, handleRemoveScope, handleRemoveUser]); + + if (isInitialLoad) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+
+ +
+
+ + {activeFilterPills.length > 0 && ( +
+ {activeFilterPills.map((pill) => ( + + ))} +
+ )} +
+
+ + {error ? ( + } title={error} /> + ) : null} + +
+ + ) : hasActiveFilters ? ( + + ) : undefined + } + /> + {hasMore && ( +
+ +
+ )} +
+ + ); +}; + +export default ActivityPage; diff --git a/client/src/protoFleet/features/activity/utils/activityIcons.tsx b/client/src/protoFleet/features/activity/utils/activityIcons.tsx new file mode 100644 index 000000000..7ae988d64 --- /dev/null +++ b/client/src/protoFleet/features/activity/utils/activityIcons.tsx @@ -0,0 +1,67 @@ +import { type ReactNode } from "react"; + +import { + Alert, + Edit, + Fan, + Groups, + type IconProps, + Info, + LEDIndicator, + Lock, + Logs, + MiningPools, + Minus, + Plus, + Power, + Racks, + Reboot, + Settings, + Speedometer, + Trash, + Unpair, +} from "@/shared/assets/icons"; + +const iconMap: Record ReactNode> = { + login: Lock, + login_failed: Alert, + logout: Lock, + update_password: Lock, + update_username: Lock, + create_user: Lock, + deactivate_user: Trash, + reset_password: Lock, + create_admin_user: Lock, + + stop_mining: Power, + start_mining: Power, + reboot: Reboot, + blink_led: LEDIndicator, + download_logs: Logs, + set_power_target: Speedometer, + set_cooling_mode: Fan, + update_mining_pools: MiningPools, + update_miner_password: Lock, + firmware_update: Settings, + unpair: Unpair, + + unpair_miners: Unpair, + rename_miners: Edit, + + create_collection: Groups, + update_collection: Groups, + delete_collection: Trash, + add_devices: Plus, + remove_devices: Minus, + set_rack_slot: Racks, + clear_rack_slot: Racks, + save_rack: Racks, + + create_pool: MiningPools, + update_pool: MiningPools, + delete_pool: Trash, +}; + +export function getActivityIcon(eventType: string): (props: IconProps) => ReactNode { + return iconMap[eventType] ?? Info; +} diff --git a/client/src/protoFleet/features/activity/utils/formatLabel.ts b/client/src/protoFleet/features/activity/utils/formatLabel.ts new file mode 100644 index 000000000..caeb0c341 --- /dev/null +++ b/client/src/protoFleet/features/activity/utils/formatLabel.ts @@ -0,0 +1 @@ +export const formatLabel = (str: string) => str.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()); diff --git a/client/src/protoFleet/features/activity/utils/formatScope.ts b/client/src/protoFleet/features/activity/utils/formatScope.ts new file mode 100644 index 000000000..e492e466a --- /dev/null +++ b/client/src/protoFleet/features/activity/utils/formatScope.ts @@ -0,0 +1,13 @@ +export function formatScope(_scopeType?: string, scopeLabel?: string, scopeCount?: number): string { + if (!scopeLabel && !scopeCount) return "\u2014"; + if (scopeLabel && scopeCount) { + const unit = scopeCount === 1 ? "miner" : "miners"; + return `${scopeLabel} (${scopeCount} ${unit})`; + } + if (scopeLabel) return scopeLabel; + if (scopeCount) { + const unit = scopeCount === 1 ? "miner" : "miners"; + return `${scopeCount} ${unit}`; + } + return "\u2014"; +} diff --git a/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.stories.tsx b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.stories.tsx new file mode 100644 index 000000000..00658c9d3 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.stories.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import AuthenticateFleetModal from "./AuthenticateFleetModal"; + +export default { + title: "Proto Fleet/Auth/AuthenticateFleetModal", + component: AuthenticateFleetModal, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onAuthenticated")({ username, password }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const SecurityPurpose = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onAuthenticated")({ username, password }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; + +export const PoolPurpose = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onAuthenticated")({ username, password }); + setOpen(false); + }} + onDismiss={() => { + action("onDismiss")(); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.tsx b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.tsx new file mode 100644 index 000000000..ed196c780 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/AuthenticateFleetModal.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { authClient } from "@/protoFleet/api/clients"; +import { Alert } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import ButtonGroup from "@/shared/components/ButtonGroup"; +import { groupVariants } from "@/shared/components/ButtonGroup/constants"; +import Callout from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import Modal from "@/shared/components/Modal/Modal"; + +interface AuthenticateFleetModalProps { + open: boolean; + purpose?: "security" | "pool" | "workerNames"; + onAuthenticated: (username: string, password: string) => void; + onDismiss: () => void; +} + +const modalTitlesByPurpose = { + security: "Log in to update your security settings", + pool: "Log in to update your pool settings", + workerNames: "Log in to update worker names", +} satisfies Record, string>; + +const AuthenticateFleetModal = ({ open, purpose, onAuthenticated, onDismiss }: AuthenticateFleetModalProps) => { + const title = purpose ? modalTitlesByPurpose[purpose] : "Log in to update settings"; + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + + // Reset form when modal is dismissed + useEffect(() => { + if (!open) { + setUsername(""); + setPassword(""); + setErrorMessage(""); + setIsVerifying(false); + } + }, [open]); + + const canContinue = username && password && !isVerifying; + + const handleContinue = useCallback(async () => { + // Clear previous error + setErrorMessage(""); + + // Validate fields + if (!username || !password) { + setErrorMessage("Username and password are required"); + return; + } + + setIsVerifying(true); + + try { + await authClient.verifyCredentials({ username, password }); + + // If successful, call onAuthenticated with the credentials + onAuthenticated(username, password); + } catch { + setErrorMessage("Invalid credentials entered."); + } finally { + setIsVerifying(false); + } + }, [username, password, onAuthenticated]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && canContinue) { + e.preventDefault(); + handleContinue(); + } + }, + [canContinue, handleContinue], + ); + + return ( + + {errorMessage ? } title={errorMessage} /> : null} + +
+ setUsername(value)} + disabled={isVerifying} + autoFocus + /> + + setPassword(value)} + disabled={isVerifying} + /> +
+ + +
+ ); +}; + +export default AuthenticateFleetModal; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/index.ts b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/index.ts new file mode 100644 index 000000000..bb87780d9 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateFleetModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AuthenticateFleetModal"; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.stories.tsx b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.stories.tsx new file mode 100644 index 000000000..bb5d92baa --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.stories.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import AuthenticateMiners from "./AuthenticateMiners"; + +export default { + title: "Proto Fleet/Auth/AuthenticateMiners", + component: AuthenticateMiners, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onClose")(); + setOpen(false); + }} + onSuccess={() => action("onSuccess")()} + /> + + ); +}; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.test.tsx b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.test.tsx new file mode 100644 index 000000000..4b26be89e --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.test.tsx @@ -0,0 +1,672 @@ +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AuthenticateMiners from "./AuthenticateMiners"; +import { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import useAuthNeededMiners from "@/protoFleet/api/useAuthNeededMiners"; +import useFleet from "@/protoFleet/api/useFleet"; +import { useMinerPairing } from "@/protoFleet/api/useMinerPairing"; +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; + +vi.mock("@/protoFleet/api/useAuthNeededMiners"); +vi.mock("@/protoFleet/api/useFleet"); +vi.mock("@/protoFleet/api/useMinerPairing"); +vi.mock("@/protoFleet/api/useOnboardedStatus"); +vi.mock("@/shared/features/toaster"); + +const mockRefetchMiners = vi.fn(); +const mockNotifyPairingCompleted = vi.fn(); + +const mockUnpairedMiners = { + miner1: { + deviceIdentifier: "miner1", + macAddress: "00:00:00:00:00:01", + model: "Proto Rig", + name: "Miner 1", + ipAddress: "192.168.1.101", + }, + miner2: { + deviceIdentifier: "miner2", + macAddress: "00:00:00:00:00:02", + model: "Proto Rig", + name: "Miner 2", + ipAddress: "192.168.1.102", + }, + miner3: { + deviceIdentifier: "miner3", + macAddress: "00:00:00:00:00:03", + model: "Proto Rig", + name: "Miner 3", + ipAddress: "192.168.1.103", + }, +} as unknown as Record; + +const mockOnClose = vi.fn(); +const mockPair = vi.fn(); +const mockRefetchOnboardingStatus = vi.fn(); +const mockRefetchFleet = vi.fn(); +const mockOnSuccess = vi.fn(); + +beforeEach(() => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1", "miner2", "miner3"], + miners: mockUnpairedMiners, + totalMiners: 3, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + loadMore: vi.fn(), + refetch: vi.fn(), + availableModels: [], + }); + + vi.mocked(useMinerPairing).mockReturnValue({ + discover: vi.fn(), + pair: mockPair, + discoverPending: false, + pairingPending: false, + }); + + vi.mocked(useOnboardedStatus).mockReturnValue({ + poolConfigured: false, + devicePaired: true, + statusLoaded: true, + refetch: mockRefetchOnboardingStatus, + }); + + vi.mocked(useFleet).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + loadMore: vi.fn(), + currentPage: 0, + hasPreviousPage: false, + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + refetch: mockRefetchFleet, + refreshCurrentPage: vi.fn(), + updateMinerWorkerName: vi.fn(), + availableModels: [], + }); + + vi.clearAllMocks(); +}); + +describe("AuthenticateMiners", () => { + const showMinersLabel = "Show miners"; + const bulkUsernameLabel = "Miner username"; + const bulkPasswordLabel = "Miner password"; + const usernameLabel = "Username"; + const passwordLabel = "Password"; + + const mockUsername = "admin"; + const mockPassword = "test1234"; + + it("renders with all miners selected by default", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + expect(getByText("3 miners selected")).toBeInTheDocument(); + }); + + it("toggles between showing and hiding miner list", () => { + const { getByText, queryByText } = render( + , + ); + + expect(queryByText("IP Address")).not.toBeInTheDocument(); + + fireEvent.click(getByText(showMinersLabel)); + expect(getByText("IP Address")).toBeInTheDocument(); + + fireEvent.click(getByText("Hide miner list")); + expect(queryByText("IP Address")).not.toBeInTheDocument(); + }); + + it("allows entering bulk credentials", async () => { + const { getByLabelText } = render( + , + ); + + const usernameInput = getByLabelText(bulkUsernameLabel); + const passwordInput = getByLabelText(bulkPasswordLabel); + + fireEvent.change(usernameInput, { target: { value: mockUsername } }); + fireEvent.change(passwordInput, { target: { value: mockPassword } }); + + expect(usernameInput).toHaveValue(mockUsername); + expect(passwordInput).toHaveValue(mockPassword); + }); + + it("autofocuses the bulk username input on mount", () => { + const { getByLabelText } = render( + , + ); + + const usernameInput = getByLabelText(bulkUsernameLabel); + expect(usernameInput).toHaveFocus(); + }); + + it("shows error when authenticating without credentials", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText("Authenticate")); + + expect(getByText("Enter a username and password and try again.")).toBeInTheDocument(); + }); + + it("shows individual credential inputs for each miner", async () => { + const { getByText, getAllByLabelText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + const usernameInputs = getAllByLabelText(usernameLabel); + const passwordInputs = getAllByLabelText(passwordLabel); + + expect(usernameInputs).toHaveLength(Object.keys(mockUnpairedMiners).length); + expect(passwordInputs).toHaveLength(Object.keys(mockUnpairedMiners).length); + }); + + it("populates individual miner inputs with bulk credentials", async () => { + const { getByText, getByLabelText, getAllByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + fireEvent.click(getByText(showMinersLabel)); + + await vi.waitFor(() => { + const usernameInputs = getAllByLabelText(usernameLabel); + const passwordInputs = getAllByLabelText(passwordLabel); + + usernameInputs.forEach((input) => { + expect(input).toHaveValue(mockUsername); + }); + passwordInputs.forEach((input) => { + expect(input).toHaveValue(mockPassword); + }); + }); + }); + + it("toggles password visibility", async () => { + const { getByText, getByLabelText, getAllByLabelText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + const passwordInputs = getAllByLabelText(passwordLabel); + passwordInputs.forEach((input) => { + expect(input).toHaveAttribute("type", "password"); + }); + + fireEvent.click(getByLabelText("Show passwords")); + + passwordInputs.forEach((input) => { + expect(input).toHaveAttribute("type", "text"); + }); + }); + + it("allows selecting and deselecting all miners", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + fireEvent.click(getByText("Select none")); + expect(getByText("0 miners selected")).toBeInTheDocument(); + + fireEvent.click(getByText("Select all")); + expect(getByText("3 miners selected")).toBeInTheDocument(); + }); + + it("filters miners by model", async () => { + const { getByText, getAllByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Find the Model dropdown filter button (not the table header) + const modelButtons = getAllByText("Model"); + // The dropdown filter button should be the first one + const modelDropdown = modelButtons[0].closest("button"); + expect(modelDropdown).toBeInTheDocument(); + + fireEvent.click(modelDropdown!); + + // Check that Proto Rig option appears (could be multiple - in dropdown and in table) + const protoRigOptions = getAllByText("Proto Rig"); + expect(protoRigOptions.length).toBeGreaterThan(0); + }); + + it("disables inputs during authentication", async () => { + const { getByText, getByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + expect(getByLabelText(bulkUsernameLabel)).not.toBeDisabled(); + expect(getByLabelText(bulkPasswordLabel)).not.toBeDisabled(); + + fireEvent.click(getByText("Authenticate")); + + expect(getByLabelText(bulkUsernameLabel)).toBeDisabled(); + expect(getByLabelText(bulkPasswordLabel)).toBeDisabled(); + }); + + it("clears individual credentials when toggling miner list", async () => { + const { getByText, getAllByLabelText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + const firstUsernameInput = getAllByLabelText(usernameLabel)[0]; + + fireEvent.change(firstUsernameInput, { + target: { value: "customuser" }, + }); + + fireEvent.click(getByText("Hide miner list")); + fireEvent.click(getByText(showMinersLabel)); + + const usernameInputs = getAllByLabelText(usernameLabel); + expect(usernameInputs[0]).not.toHaveValue("customuser"); + }); + + it("calls pair API with bulk credentials when authenticate is clicked", async () => { + const { getByText, getByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + fireEvent.click(getByText("Authenticate")); + + expect(mockPair).toHaveBeenCalledTimes(1); + // Bulk mode uses allDevices selector with AUTHENTICATION_NEEDED pairing status filter + expect(mockPair).toHaveBeenCalledWith( + expect.objectContaining({ + pairRequest: expect.objectContaining({ + credentials: expect.objectContaining({ + username: mockUsername, + password: mockPassword, + }), + deviceSelector: expect.objectContaining({ + selectionType: expect.objectContaining({ + case: "allDevices", + }), + }), + }), + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + + it("groups miners with same credentials into single pair request", async () => { + const { getByText, getByLabelText, getAllByLabelText } = render( + , + ); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: "bulk-user" }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: "bulk-pass" }, + }); + + fireEvent.click(getByText(showMinersLabel)); + const usernameInputs = getAllByLabelText(usernameLabel); + const passwordInputs = getAllByLabelText(passwordLabel); + + fireEvent.change(usernameInputs[0], { + target: { value: "custom-user" }, + }); + fireEvent.change(passwordInputs[0], { + target: { value: "custom-pass" }, + }); + + fireEvent.click(getByText("Authenticate")); + + // Should make 2 pair requests: one for custom credentials, one for bulk + expect(mockPair).toHaveBeenCalledTimes(2); + }); + + it("calls refetch after successful authentication", async () => { + const { getByText, getByLabelText } = render( + , + ); + + mockPair.mockImplementation(({ onSuccess }) => { + onSuccess([]); + }); + + fireEvent.change(getByLabelText(bulkUsernameLabel), { + target: { value: mockUsername }, + }); + fireEvent.change(getByLabelText(bulkPasswordLabel), { + target: { value: mockPassword }, + }); + + fireEvent.click(getByText("Authenticate")); + + await vi.waitFor(() => { + expect(mockRefetchOnboardingStatus).toHaveBeenCalled(); + expect(mockRefetchMiners).toHaveBeenCalled(); + expect(mockNotifyPairingCompleted).toHaveBeenCalled(); + expect(mockOnSuccess).toHaveBeenCalled(); + }); + }); + + it("displays correct total devices count", () => { + const { getByText } = render( + , + ); + + expect(getByText("3 miners remaining")).toBeInTheDocument(); + }); + + it("disables authenticate button when no miners are selected", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + fireEvent.click(getByText("Select none")); + + const authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).toBeDisabled(); + }); + + it("enables authenticate button when miners are selected", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // By default all miners are selected + const authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).not.toBeDisabled(); + }); + + it("re-enables authenticate button after selecting miners", () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Deselect all + fireEvent.click(getByText("Select none")); + let authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).toBeDisabled(); + + // Select all again + fireEvent.click(getByText("Select all")); + authenticateButton = getByText("Authenticate").closest("button"); + expect(authenticateButton).not.toBeDisabled(); + }); + + describe("selection persistence", () => { + it("preserves empty selection when user deselects all miners", async () => { + const { getByText } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Initially all miners selected + expect(getByText("3 miners selected")).toBeInTheDocument(); + + // User deselects all + fireEvent.click(getByText("Select none")); + expect(getByText("0 miners selected")).toBeInTheDocument(); + + // Selection should remain empty (not auto-select all again) + await vi.waitFor( + () => { + expect(getByText("0 miners selected")).toBeInTheDocument(); + }, + { timeout: 500 }, + ); + }); + + it("does not reset selection to all when miner list updates", async () => { + const mockRefetch = vi.fn(); + const { getByText, rerender } = render( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Initially all 3 miners selected + expect(getByText("3 miners selected")).toBeInTheDocument(); + + // User deselects all + fireEvent.click(getByText("Select none")); + expect(getByText("0 miners selected")).toBeInTheDocument(); + + // Simulate miner list update (e.g., after authentication removes some miners) + const remainingMiners = { + miner3: mockUnpairedMiners.miner3, + }; + + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner3"], + miners: remainingMiners, + totalMiners: 1, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: mockRefetch, + }); + + // Trigger re-render with updated miner list + rerender( + , + ); + + // Selection should NOT reset to "all miners" - should remain empty + // Before the fix, this would show "1 miner selected" + await vi.waitFor(() => { + const selectionText = getByText(/miners selected/); + expect(selectionText.textContent).toBe("0 miners selected"); + }); + }); + + it("initializes with all miners selected on first load", () => { + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: [], + miners: {}, + totalMiners: 0, + hasMore: false, + isLoading: true, + hasInitialLoadCompleted: false, + availableModels: [], + loadMore: vi.fn(), + refetch: vi.fn(), + }); + + const { getByText, rerender } = render( + , + ); + + // No miners loaded yet - should show 0 + expect(getByText("0 miners remaining")).toBeInTheDocument(); + + // Miners load + vi.mocked(useAuthNeededMiners).mockReturnValue({ + minerIds: ["miner1", "miner2", "miner3"], + miners: mockUnpairedMiners, + totalMiners: 3, + hasMore: false, + isLoading: false, + hasInitialLoadCompleted: true, + availableModels: [], + loadMore: vi.fn(), + refetch: vi.fn(), + }); + + rerender( + , + ); + + fireEvent.click(getByText(showMinersLabel)); + + // Should auto-select all miners on initial load + expect(getByText("3 miners selected")).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.tsx b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.tsx new file mode 100644 index 000000000..4c59e648f --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/AuthenticateMiners.tsx @@ -0,0 +1,549 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { create } from "@bufbuild/protobuf"; +import { DeviceIdentifierListSchema } from "@/protoFleet/api/generated/common/v1/device_selector_pb"; +import { PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceFilterSchema, DeviceSelectorSchema } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; +import { CredentialsSchema, PairRequestSchema } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; +import useAuthNeededMiners from "@/protoFleet/api/useAuthNeededMiners"; +import { useMinerPairing } from "@/protoFleet/api/useMinerPairing"; +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; +import { ids } from "@/protoFleet/features/auth/components/AuthenticateMiners/constants"; +import { Credentials, UnauthenticatedMiner } from "@/protoFleet/features/auth/components/AuthenticateMiners/types"; +import { createModelFilter, filterByModel } from "@/protoFleet/utils/minerFilters"; +import { Alert } from "@/shared/assets/icons"; +import { sizes, variants } from "@/shared/components/Button/constants"; +import Callout, { intents } from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import List from "@/shared/components/List"; +import { ActiveFilters } from "@/shared/components/List/Filters/types"; +import Modal, { ModalSelectAllFooter } from "@/shared/components/Modal"; + +import Switch from "@/shared/components/Switch"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; + +const activeCols = ["model", "ipAddress", "username", "password"] as (keyof UnauthenticatedMiner)[]; + +const colTitles = { + model: "Model", + deviceIdentifier: "ID", + macAddress: "MAC Address", + ipAddress: "IP Address", + username: "Username", + password: "Password", +} as { + [key in (typeof activeCols)[number]]: string; +}; + +type AuthenticateMinersProps = { + open?: boolean; + onClose: () => void; + onSuccess?: () => void; + onPairingCompleted?: () => void; + onRefetchMiners?: () => void; +}; + +const AuthenticateMiners = ({ + open, + onClose, + onSuccess, + onPairingCompleted, + onRefetchMiners, +}: AuthenticateMinersProps) => { + const isVisible = open ?? true; + // Component fetches its own data + const { + miners: minersByIdentifier, + refetch: refetchAuthNeededMiners, + totalMiners, + } = useAuthNeededMiners({ + enabled: isVisible, + }); + const { pair } = useMinerPairing(); + const { refetch: refetchOnboardingStatus } = useOnboardedStatus({ enabled: isVisible }); + + // Track if component is mounted to prevent state updates after unmount + const isMountedRef = useRef(true); + + // Stable reference to track authentication completion across re-renders + const completionTrackerRef = useRef<{ + completed: number; + total: number; + failedMiners: string[]; + }>({ + completed: 0, + total: 0, + failedMiners: [], + }); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!isVisible) { + setBulkCredentials({ username: "", password: "" }); + setCredentials({}); + setHasMissingCredentials(false); + setMinerErrors([]); + setAuthenticateLoading(false); + setShowMiners(false); + setShowPasswords(false); + hasInitializedSelectionRef.current = false; + } + }, [isVisible]); + + const [bulkCredentials, setBulkCredentials] = useState({ + username: "", + password: "", + }); + // stores credentials for each miner, keyed by deviceIdentifier + const [credentials, setCredentials] = useState>({}); + const [hasMissingCredentials, setHasMissingCredentials] = useState(false); + // stores ids of miners that have errors + const [minerErrors, setMinerErrors] = useState([]); + const [authenticateLoading, setAuthenticateLoading] = useState(false); + + const errorMessage = useMemo(() => { + if (hasMissingCredentials) { + return "Enter a username and password and try again."; + } + if (minerErrors && minerErrors.length > 0) { + return "Try your username and password again."; + } + return null; + }, [hasMissingCredentials, minerErrors]); + + const handleBulkChange = useCallback( + (value: string, id: string) => { + setBulkCredentials({ ...bulkCredentials, [id]: value.trim() }); + }, + [bulkCredentials], + ); + + const handleMinerChange = useCallback( + (deviceIdentifier: string, key: string, value: string) => { + const newValue = { ...credentials }; + newValue[deviceIdentifier] = { + ...(credentials[deviceIdentifier] || {}), + [key]: value.trim(), + }; + setCredentials(newValue); + }, + [credentials], + ); + + const [showMiners, setShowMiners] = useState(false); + const [showPasswords, setShowPasswords] = useState(false); + + const minerItems: UnauthenticatedMiner[] = useMemo(() => { + return Object.values(minersByIdentifier).map((device) => ({ + deviceIdentifier: device.deviceIdentifier, + model: device.model, + macAddress: device.macAddress || "", + ipAddress: device.ipAddress || "", + username: "", + password: "", + })); + }, [minersByIdentifier]); + + const [selectedMiners, setSelectedMiners] = useState([]); + // Track if we've initialized selection to prevent unwanted resets + const hasInitializedSelectionRef = useRef(false); + const [activeFilters, setActiveFilters] = useState({ + buttonFilters: [], + dropdownFilters: {}, + }); + + // Initialize selection to all miners only on first data load + // After initial load, preserve user selection even when miner list updates + useEffect(() => { + const minerIds = Object.keys(minersByIdentifier); + if (!hasInitializedSelectionRef.current && minerIds.length > 0) { + setSelectedMiners(minerIds); + hasInitializedSelectionRef.current = true; + } + }, [minersByIdentifier]); + + const models = useMemo(() => { + return Array.from(new Set(minerItems.map((miner) => miner.model))); + }, [minerItems]); + + const modelFilter = useMemo(() => createModelFilter(models), [models]); + + const filters = useMemo(() => [modelFilter], [modelFilter]); + + const filteredMiners = useMemo(() => { + return minerItems.filter((miner) => filterByModel(miner, activeFilters)); + }, [minerItems, activeFilters]); + + const colConfig = useMemo(() => { + return { + model: { + width: "w-40", + }, + macAddress: { + width: "w-40", + }, + username: { + component: (item: UnauthenticatedMiner) => ( + id === item.deviceIdentifier) !== undefined} + onChange={handleMinerChange.bind(this, item.deviceIdentifier, ids.username)} + /> + ), + width: "w-70 !py-3", + }, + password: { + component: (item: UnauthenticatedMiner) => ( + id === item.deviceIdentifier) !== undefined} + onChange={handleMinerChange.bind(this, item.deviceIdentifier, ids.password)} + /> + ), + width: "w-70 !py-3", + }, + }; + }, [handleMinerChange, bulkCredentials, showPasswords, authenticateLoading, minerErrors, credentials]); + + // Helper to perform common post-authentication operations + const handleAuthenticationComplete = useCallback( + (successCount: number) => { + refetchOnboardingStatus(); + onRefetchMiners?.(); + refetchAuthNeededMiners(); + onPairingCompleted?.(); + if (successCount > 0) { + onSuccess?.(); + } + }, + [refetchOnboardingStatus, onRefetchMiners, refetchAuthNeededMiners, onPairingCompleted, onSuccess], + ); + + const authenticateMiners = useCallback(() => { + if ( + (bulkCredentials.username === "" || bulkCredentials.password === "") && + Object.entries(credentials).length === 0 + ) { + setHasMissingCredentials(true); + return; + } + + setHasMissingCredentials(false); + setAuthenticateLoading(true); + + // Determine if we can use bulk mode (all miners with same credentials) + const hasIndividualCredentials = Object.keys(credentials).length > 0; + const useBulkMode = + !showMiners && !hasIndividualCredentials && bulkCredentials.username && bulkCredentials.password; + + if (useBulkMode) { + // Bulk mode: Use all_devices selector with AUTHENTICATION_NEEDED filter + // This allows authenticating all auth-needed miners without pagination limits + const pairRequest = create(PairRequestSchema, { + credentials: create(CredentialsSchema, { + username: bulkCredentials.username, + password: bulkCredentials.password, + }), + deviceSelector: create(DeviceSelectorSchema, { + selectionType: { + case: "allDevices", + value: create(DeviceFilterSchema, { + pairingStatus: [PairingStatus.AUTHENTICATION_NEEDED], + }), + }, + }), + }); + + pair({ + pairRequest, + onSuccess: (failedDeviceIds) => { + if (!isMountedRef.current) return; + + setAuthenticateLoading(false); + setMinerErrors(failedDeviceIds); + + const successCount = totalMiners - failedDeviceIds.length; + const allSucceeded = failedDeviceIds.length === 0; + const allFailed = failedDeviceIds.length === totalMiners; + + if (allSucceeded) { + pushToast({ + message: "All miners authenticated.", + status: TOAST_STATUSES.success, + }); + onClose(); + } else if (allFailed) { + pushToast({ + message: "Authentication failed. Please check your credentials and try again.", + status: TOAST_STATUSES.error, + }); + } else { + pushToast({ + message: `You authenticated ${successCount} of ${totalMiners} miners.`, + status: TOAST_STATUSES.error, + }); + } + + handleAuthenticationComplete(successCount); + }, + onError: (error) => { + if (!isMountedRef.current) return; + + console.error("Pairing error:", error); + setAuthenticateLoading(false); + pushToast({ + message: "Authentication failed. Please check your credentials and try again.", + status: TOAST_STATUSES.error, + }); + }, + }); + return; + } + + // Individual mode: Group selected miners by their credentials + // Uses include_devices selector with explicit device identifiers + const credentialGroups = new Map(); + + selectedMiners.forEach((deviceId) => { + const minerCreds = credentials[deviceId] || bulkCredentials; + const key = `${minerCreds.username}|||${minerCreds.password}`; + + const existing = credentialGroups.get(key); + if (existing) { + existing.deviceIds.push(deviceId); + } else { + credentialGroups.set(key, { + creds: minerCreds, + deviceIds: [deviceId], + }); + } + }); + + // Initialize or reset the completion tracker + completionTrackerRef.current = { + completed: 0, + total: credentialGroups.size, + failedMiners: [] as string[], + }; + + const handleRequestComplete = () => { + completionTrackerRef.current.completed++; + + // Only process final results if all requests are complete + if (completionTrackerRef.current.completed !== completionTrackerRef.current.total) return; + + // Check if component is still mounted before updating state + if (!isMountedRef.current) return; + + setAuthenticateLoading(false); + setMinerErrors(completionTrackerRef.current.failedMiners); + + const successCount = selectedMiners.length - completionTrackerRef.current.failedMiners.length; + const allSucceeded = completionTrackerRef.current.failedMiners.length === 0; + const allFailed = completionTrackerRef.current.failedMiners.length === selectedMiners.length; + const loadedMinersCount = Object.keys(minersByIdentifier).length; + const allMinersAuthenticated = allSucceeded && successCount === loadedMinersCount; + + if (allMinersAuthenticated) { + pushToast({ + message: "All miners authenticated.", + status: TOAST_STATUSES.success, + }); + // Close modal after all miners in the list are successfully authenticated + onClose(); + } else if (allSucceeded) { + pushToast({ + message: `${successCount} ${successCount === 1 ? "miner" : "miners"} authenticated.`, + status: TOAST_STATUSES.success, + }); + } else if (allFailed) { + pushToast({ + message: "Authentication failed. Please check your credentials and try again.", + status: TOAST_STATUSES.error, + }); + } else { + pushToast({ + message: `You authenticated ${successCount} of ${selectedMiners.length} miners.`, + status: TOAST_STATUSES.error, + }); + } + + handleAuthenticationComplete(successCount); + }; + + // Make a pair request for each credential group using include_devices selector + credentialGroups.forEach(({ creds, deviceIds }) => { + const pairRequest = create(PairRequestSchema, { + credentials: create(CredentialsSchema, { + username: creds.username, + password: creds.password, + }), + deviceSelector: create(DeviceSelectorSchema, { + selectionType: { + case: "includeDevices", + value: create(DeviceIdentifierListSchema, { + deviceIdentifiers: deviceIds, + }), + }, + }), + }); + + pair({ + pairRequest, + onSuccess: (failedDeviceIds) => { + // Safely aggregate failed device IDs + completionTrackerRef.current.failedMiners.push(...failedDeviceIds); + handleRequestComplete(); + }, + onError: (error) => { + console.error("Pairing error:", error); + // On error, mark all devices in this group as failed + completionTrackerRef.current.failedMiners.push(...deviceIds); + handleRequestComplete(); + }, + }); + }); + }, [ + bulkCredentials, + credentials, + selectedMiners, + minersByIdentifier, + showMiners, + totalMiners, + handleAuthenticationComplete, + onClose, + pair, + ]); + + return ( + { + setCredentials({}); + setMinerErrors([]); + setShowMiners((prev) => !prev); + }, + }, + { + variant: variants.primary, + text: "Authenticate", + dismissModalOnClick: false, + loading: authenticateLoading, + disabled: selectedMiners.length === 0, + onClick: authenticateMiners, + }, + ]} + size={showMiners ? "large" : undefined} + title="Authenticate miners" + description={ + !showMiners + ? "If miners use different credentials, we'll try each attempt until all miners are configured." + : undefined + } + > + {errorMessage !== null && ( + } + title={errorMessage} + dismissible + onDismiss={() => { + setHasMissingCredentials(false); + setMinerErrors([]); + }} + /> + )} +
+
+
+
Bulk authenticate
+
+ {totalMiners} {totalMiners === 1 ? "miner" : "miners"} remaining +
+
+ + +
+
+ {showMiners && ( + <> +
+ + filters={filters} + filterItem={filterByModel} + onFilterChange={setActiveFilters} + filterSize={sizes.compact} + headerControls={} + activeCols={activeCols} + colTitles={colTitles} + colConfig={colConfig} + items={minerItems} + itemKey="deviceIdentifier" + itemSelectable + customSelectedItems={selectedMiners} + customSetSelectedItems={setSelectedMiners} + containerClassName="max-h-[50vh]" + stickyBgColor="bg-surface-elevated-base" + /> +
+ setSelectedMiners(filteredMiners.map((miner) => miner.deviceIdentifier))} + onSelectNone={() => setSelectedMiners([])} + /> + + )} +
+ ); +}; + +export default AuthenticateMiners; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/constants.ts b/client/src/protoFleet/features/auth/components/AuthenticateMiners/constants.ts new file mode 100644 index 000000000..ea785e604 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/constants.ts @@ -0,0 +1,4 @@ +export const ids = { + username: "username", + password: "password", +}; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/index.ts b/client/src/protoFleet/features/auth/components/AuthenticateMiners/index.ts new file mode 100644 index 000000000..6da2d460e --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/index.ts @@ -0,0 +1,3 @@ +import AuthenticateMiners from "./AuthenticateMiners"; + +export { AuthenticateMiners }; diff --git a/client/src/protoFleet/features/auth/components/AuthenticateMiners/types.ts b/client/src/protoFleet/features/auth/components/AuthenticateMiners/types.ts new file mode 100644 index 000000000..c0a6bd59a --- /dev/null +++ b/client/src/protoFleet/features/auth/components/AuthenticateMiners/types.ts @@ -0,0 +1,15 @@ +// UnauthenticatedMiner represents the data structure used in the authentication flow +// It contains basic miner info along with credential fields +export type UnauthenticatedMiner = { + deviceIdentifier: string; + model: string; + macAddress: string; + ipAddress: string; + username: string; + password: string; +}; + +export type Credentials = { + username: string; + password: string; +}; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/LoginForm.tsx b/client/src/protoFleet/features/auth/components/LoginModal/LoginForm.tsx new file mode 100644 index 000000000..a8f7c4138 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/LoginForm.tsx @@ -0,0 +1,142 @@ +import { useCallback, useState } from "react"; +import clsx from "clsx"; + +import { create } from "@bufbuild/protobuf"; +import { AuthenticateRequestSchema } from "@/protoFleet/api/generated/auth/v1/auth_pb"; +import { useLogin } from "@/protoFleet/api/useLogin"; +import { ids, initValues, type Values } from "@/protoFleet/features/auth/components/LoginModal"; +import { useSetTemporaryPassword } from "@/protoFleet/store"; + +import { Alert, Logo } from "@/shared/assets/icons"; +import { variants } from "@/shared/components/Button"; +import ButtonGroup, { ButtonProps, groupVariants, sizes } from "@/shared/components/ButtonGroup"; +import Callout from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import { useKeyDown } from "@/shared/hooks/useKeyDown"; + +import { deepClone } from "@/shared/utils/utility"; + +interface LoginFormProps { + onDismiss?: () => void; + onSuccess: (requiresPasswordChange: boolean) => void; +} + +const LoginForm = ({ onDismiss, onSuccess }: LoginFormProps) => { + const [values, setValues] = useState(deepClone(initValues)); + const [errors, setErrors] = useState(deepClone(initValues)); + const [apiError, setApiError] = useState(null); + const login = useLogin(); + const [isSubmitting, setIsSubmitting] = useState(false); + const setTemporaryPassword = useSetTemporaryPassword(); + + const handleChange = useCallback( + (value: string, id: string) => { + setValues({ ...values, [id]: value.trim() }); + // clear errors if the user starts typing + setErrors(deepClone(initValues)); + setApiError(null); + }, + [values], + ); + + const handleContinue = useCallback(() => { + setIsSubmitting(true); + login({ + loginRequest: create(AuthenticateRequestSchema, { + username: values.username, + password: values.password, + }), + onSuccess: (requiresPasswordChange: boolean) => { + if (requiresPasswordChange) { + setTemporaryPassword(values.password); + } + onSuccess(requiresPasswordChange); + }, + onError: () => setApiError("Invalid credentials entered."), + onFinally: () => setIsSubmitting(false), + }); + }, [login, values.username, values.password, onSuccess, setTemporaryPassword]); + + const handleEnter = useCallback(() => { + if (isSubmitting) { + return; + } + + handleContinue(); + }, [isSubmitting, handleContinue]); + + useKeyDown({ key: "Enter", onKeyDown: handleEnter }); + + return ( +
+ +
+
+
+
Log in
+
+ +
+
+ } title="Invalid credentials entered." /> +
+ + + + +
+
+ + !!button.text) as ButtonProps[] + } + /> +
+
+
Powerful mining tools. Built for decentralization.
+
© {new Date().getFullYear()} Block, Inc.
+
+
+ ); +}; + +export default LoginForm; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/constants.ts b/client/src/protoFleet/features/auth/components/LoginModal/constants.ts new file mode 100644 index 000000000..2868d9db3 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/constants.ts @@ -0,0 +1,11 @@ +export const ids = { + username: "username", + password: "password", + confirmPassword: "confirmPassword", +}; + +export const initValues = { + username: "", + password: "", + confirmPassword: "", +}; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/index.ts b/client/src/protoFleet/features/auth/components/LoginModal/index.ts new file mode 100644 index 000000000..8cc08fd5a --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/index.ts @@ -0,0 +1,4 @@ +import { ids, initValues } from "./constants"; +import { Values } from "./types"; + +export { ids, initValues, type Values }; diff --git a/client/src/protoFleet/features/auth/components/LoginModal/types.ts b/client/src/protoFleet/features/auth/components/LoginModal/types.ts new file mode 100644 index 000000000..35ab90184 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/LoginModal/types.ts @@ -0,0 +1,5 @@ +export interface Values { + username: string; + password: string; + confirmPassword: string; +} diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordForm.stories.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.stories.tsx new file mode 100644 index 000000000..4dbadddfd --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.stories.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import { UpdatePasswordForm } from "./UpdatePasswordForm"; + +export default { + title: "Proto Fleet/Auth/UpdatePasswordForm", + component: UpdatePasswordForm, +}; + +// Default story +export const Default = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg="" + /> + ); +}; + +// With error message +export const WithError = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg="Passwords do not match. Please try again." + /> + ); +}; + +// With validation error +export const WithWeakPasswordError = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg="Password must be at least 8 characters and include uppercase, lowercase, number, and special character." + /> + ); +}; + +// Loading state +export const LoadingState = () => { + return ( + { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={true} + errorMsg="" + /> + ); +}; + +// Interactive demo +export const Interactive = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + + const handleSubmit = (newPassword: string, confirmPassword: string) => { + action("onSubmit")(newPassword, confirmPassword); + setErrorMsg(""); + + // Validate passwords match + if (newPassword !== confirmPassword) { + setErrorMsg("Passwords do not match. Please try again."); + return; + } + + // Validate password strength (basic check) + if (newPassword.length < 8) { + setErrorMsg("Password must be at least 8 characters long."); + return; + } + + // Simulate API call + setIsSubmitting(true); + setTimeout(() => { + setIsSubmitting(false); + action("Success!")(); + }, 2000); + }; + + return ( +
+
+ Try entering mismatched passwords or a weak password to see validation errors. Enter matching strong passwords + to simulate success (2 second delay). +
+ setErrorMsg("")} + /> +
+ ); +}; + +// With strong password +export const WithStrongPassword = () => { + const [errorMsg, setErrorMsg] = useState(""); + + return ( +
+
+ Try entering: "StrongP@ssw0rd" to see a strong password score +
+ { + action("onSubmit")(newPassword, confirmPassword); + }} + isSubmitting={false} + errorMsg={errorMsg} + onErrorDismiss={() => setErrorMsg("")} + /> +
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordForm.test.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.test.tsx new file mode 100644 index 000000000..9b4860797 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpdatePasswordForm } from "./UpdatePasswordForm"; + +const mockOnSubmit = vi.fn(); +const mockOnErrorDismiss = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("UpdatePasswordForm", () => { + it("renders form with password inputs", () => { + const { getByLabelText, getByText } = render(); + + expect(getByText("Update Your Password")).toBeInTheDocument(); + expect(getByLabelText("New password")).toBeInTheDocument(); + expect(getByLabelText("Confirm password")).toBeInTheDocument(); + expect(getByText("Continue")).toBeInTheDocument(); + }); + + it("allows entering password values", () => { + const { getByLabelText } = render(); + + const newPasswordInput = getByLabelText("New password"); + const confirmPasswordInput = getByLabelText("Confirm password"); + + fireEvent.change(newPasswordInput, { target: { value: "NewPass123!@#" } }); + fireEvent.change(confirmPasswordInput, { + target: { value: "NewPass123!@#" }, + }); + + expect(newPasswordInput).toHaveValue("NewPass123!@#"); + expect(confirmPasswordInput).toHaveValue("NewPass123!@#"); + }); + + it("calls onSubmit with password values when Continue is clicked", () => { + const { getByLabelText, getByText } = render(); + + const newPasswordInput = getByLabelText("New password"); + const confirmPasswordInput = getByLabelText("Confirm password"); + + fireEvent.change(newPasswordInput, { target: { value: "NewPass123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "NewPass123" } }); + + fireEvent.click(getByText("Continue")); + + expect(mockOnSubmit).toHaveBeenCalledWith("NewPass123", "NewPass123"); + }); + + it("displays error message when provided", () => { + const { getByText } = render(); + + expect(getByText("Passwords do not match")).toBeInTheDocument(); + }); + + it("calls onErrorDismiss when typing in new password field", () => { + const { getByLabelText } = render( + , + ); + + const newPasswordInput = getByLabelText("New password"); + fireEvent.change(newPasswordInput, { target: { value: "test" } }); + + expect(mockOnErrorDismiss).toHaveBeenCalled(); + }); + + it("calls onErrorDismiss when typing in confirm password field", () => { + const { getByLabelText } = render( + , + ); + + const confirmPasswordInput = getByLabelText("Confirm password"); + fireEvent.change(confirmPasswordInput, { target: { value: "test" } }); + + expect(mockOnErrorDismiss).toHaveBeenCalled(); + }); + + it("shows loading state when isSubmitting is true", () => { + const { getByText } = render(); + + expect(getByText("Updating...")).toBeInTheDocument(); + }); + + it("disables button when isSubmitting is true", () => { + const { getByText } = render(); + + const continueButton = getByText("Updating...").closest("button"); + expect(continueButton).toBeDisabled(); + }); + + it("renders password strength meter", () => { + const { getByText } = render(); + + expect(getByText("Password strength")).toBeInTheDocument(); + }); + + it("renders Logo component", () => { + const { container } = render(); + + const logo = container.querySelector("svg"); + expect(logo).toBeTruthy(); + }); + + it("renders Footer component", () => { + const { container } = render(); + + expect(container.querySelector("footer")).toBeTruthy(); + }); + + it("displays error message with correct styling", () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText("Invalid password format")).toBeInTheDocument(); + expect(getByTestId("callout")).toBeInTheDocument(); + }); + + it("shows validation error when submitting with empty passwords", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Continue")); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + expect(getByText("Minimum 8 characters required")).toBeInTheDocument(); + }); + + it("updates password strength meter when password changes", () => { + const { getByLabelText } = render(); + + const newPasswordInput = getByLabelText("New password"); + + fireEvent.change(newPasswordInput, { target: { value: "weak" } }); + + fireEvent.change(newPasswordInput, { + target: { value: "StrongP@ssw0rd123!" }, + }); + + expect(newPasswordInput).toHaveValue("StrongP@ssw0rd123!"); + }); + + it("handles long error messages", () => { + const longError = + "Password must be at least 12 characters long and include uppercase letters, lowercase letters, numbers, and special characters. Please try again."; + + const { getByText } = render(); + + expect(getByText(longError)).toBeInTheDocument(); + }); + + it("does not show error message when errorMsg is empty string", () => { + const { queryByTestId } = render(); + + expect(queryByTestId("callout")).toBeFalsy(); + }); + + it("renders descriptive text about temporary password", () => { + const { getByText } = render(); + + expect( + getByText("You logged in with a temporary password. Enter your new password to continue."), + ).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordForm.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.tsx new file mode 100644 index 000000000..0ed576587 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordForm.tsx @@ -0,0 +1,121 @@ +import { useCallback, useState } from "react"; +import Footer from "@/protoFleet/components/Footer"; +import { Alert, Logo } from "@/shared/assets/icons"; +import Button from "@/shared/components/Button"; +import Callout from "@/shared/components/Callout"; +import Header from "@/shared/components/Header"; +import Input from "@/shared/components/Input"; +import { PasswordStrengthMeter, WeakPasswordWarning } from "@/shared/components/Setup"; +import { isPasswordTooShort, isWeakPassword, passwordErrors } from "@/shared/components/Setup/authentication.constants"; + +interface UpdatePasswordFormProps { + onSubmit: (newPassword: string, confirmPassword: string) => void; + isSubmitting?: boolean; + errorMsg?: string; + onErrorDismiss?: () => void; +} + +export const UpdatePasswordForm = ({ + onSubmit, + isSubmitting = false, + errorMsg = "", + onErrorDismiss, +}: UpdatePasswordFormProps) => { + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [score, setScore] = useState(0); + const [validationError, setValidationError] = useState(""); + const [showWeakPasswordWarning, setShowWeakPasswordWarning] = useState(false); + + const handlePasswordChange = (value: string) => { + setNewPassword(value); + setValidationError(""); + onErrorDismiss?.(); + }; + + const handleConfirmPasswordChange = (value: string) => { + setConfirmPassword(value); + setValidationError(""); + onErrorDismiss?.(); + }; + + const handleSubmit = useCallback( + (forcedWeakPassword: boolean) => { + // Validate password length + if (isPasswordTooShort(newPassword)) { + setValidationError(passwordErrors.tooShort); + return; + } + + // Validate passwords match + if (newPassword !== confirmPassword) { + setValidationError(passwordErrors.mismatch); + return; + } + + // Check for weak password + if (!forcedWeakPassword && isWeakPassword(score)) { + setShowWeakPasswordWarning(true); + return; + } + + setShowWeakPasswordWarning(false); + onSubmit(newPassword, confirmPassword); + }, + [newPassword, confirmPassword, score, onSubmit], + ); + + return ( +
+
+
+
+ +
+
+ + {errorMsg || validationError ? ( + } title={errorMsg || validationError} /> + ) : null} + +
+
+ +
+
+
Password strength
+
+ +
+
+ + +
+ + {showWeakPasswordWarning && !isSubmitting && ( + setShowWeakPasswordWarning(false)} + onContinue={() => handleSubmit(true)} + /> + )} + + +
+
+
+
+
+
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.stories.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.stories.tsx new file mode 100644 index 000000000..4274ec1f1 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.stories.tsx @@ -0,0 +1,35 @@ +import { action } from "storybook/actions"; +import { UpdatePasswordSuccess } from "./UpdatePasswordSuccess"; + +export default { + title: "Proto Fleet/Auth/UpdatePasswordSuccess", + component: UpdatePasswordSuccess, +}; + +// Default story +export const Default = () => { + return ( + { + action("onLogin")(); + }} + /> + ); +}; + +// Interactive demo +export const Interactive = () => { + return ( +
+
+ Click the "Login" button to proceed to the login screen +
+ { + action("onLogin")(); + alert("Redirecting to login..."); + }} + /> +
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.test.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.test.tsx new file mode 100644 index 000000000..c68b1392b --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UpdatePasswordSuccess } from "./UpdatePasswordSuccess"; + +const mockOnLogin = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("UpdatePasswordSuccess", () => { + it("renders success message", () => { + const { getByText } = render(); + + expect(getByText("Password saved")).toBeInTheDocument(); + expect(getByText("Password updated.")).toBeInTheDocument(); + }); + + it("renders Login button", () => { + const { getByText } = render(); + + expect(getByText("Login")).toBeInTheDocument(); + }); + + it("calls onLogin when Login button is clicked", () => { + const { getByText } = render(); + + fireEvent.click(getByText("Login")); + + expect(mockOnLogin).toHaveBeenCalled(); + }); + + it("renders Logo component", () => { + const { container } = render(); + + const logo = container.querySelector("svg"); + expect(logo).toBeTruthy(); + }); + + it("renders Footer component", () => { + const { container } = render(); + + expect(container.querySelector("footer")).toBeTruthy(); + }); + + it("uses correct heading size", () => { + const { getByText } = render(); + + const heading = getByText("Password saved"); + expect(heading.className).toContain("text-heading-300"); + }); + + it("button has primary variant styling", () => { + const { getByText } = render(); + + const button = getByText("Login"); + expect(button).toBeInTheDocument(); + }); + + it("renders with proper layout structure", () => { + const { container } = render(); + + const mainContainer = container.querySelector(".h-screen"); + expect(mainContainer).toBeTruthy(); + + const contentWrapper = container.querySelector(".max-w-100"); + expect(contentWrapper).toBeTruthy(); + }); + + it("calls onLogin when button is clicked", () => { + const { getByText } = render(); + + const loginButton = getByText("Login"); + + fireEvent.click(loginButton); + expect(mockOnLogin).toHaveBeenCalledTimes(1); + }); + + it("renders description text with correct styling", () => { + const { getByText } = render(); + + const description = getByText("Password updated."); + expect(description.className).toContain("text-300"); + }); +}); diff --git a/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.tsx b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.tsx new file mode 100644 index 000000000..8f7d3d3d2 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/UpdatePasswordSuccess.tsx @@ -0,0 +1,29 @@ +import Footer from "@/protoFleet/components/Footer"; +import { Logo } from "@/shared/assets/icons"; +import Button from "@/shared/components/Button"; +import Header from "@/shared/components/Header"; + +interface UpdatePasswordSuccessProps { + onLogin: () => void; +} + +export const UpdatePasswordSuccess = ({ onLogin }: UpdatePasswordSuccessProps) => { + return ( +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/client/src/protoFleet/features/auth/components/index.ts b/client/src/protoFleet/features/auth/components/index.ts new file mode 100644 index 000000000..01d48e330 --- /dev/null +++ b/client/src/protoFleet/features/auth/components/index.ts @@ -0,0 +1,2 @@ +export { UpdatePasswordForm } from "./UpdatePasswordForm"; +export { UpdatePasswordSuccess } from "./UpdatePasswordSuccess"; diff --git a/client/src/protoFleet/features/auth/pages/Auth/Auth.tsx b/client/src/protoFleet/features/auth/pages/Auth/Auth.tsx new file mode 100644 index 000000000..b5032ed6e --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/Auth/Auth.tsx @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useNavigate as useReactNavigate } from "react-router-dom"; +import Footer from "@/protoFleet/components/Footer"; +import LoginForm from "@/protoFleet/features/auth/components/LoginModal/LoginForm"; + +const Auth = () => { + const navigate = useReactNavigate(); + + const handleLoginSuccess = useCallback( + (requiresPasswordChange: boolean) => { + if (requiresPasswordChange) { + navigate("/update-password"); + } else { + navigate("/"); + } + }, + [navigate], + ); + + return ( +
+
+
+ +
+
+
+
+ ); +}; + +export default Auth; diff --git a/client/src/protoFleet/features/auth/pages/Auth/index.ts b/client/src/protoFleet/features/auth/pages/Auth/index.ts new file mode 100644 index 000000000..0e61b03ba --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/Auth/index.ts @@ -0,0 +1,3 @@ +import Auth from "./Auth"; + +export default Auth; diff --git a/client/src/protoFleet/features/auth/pages/UpdatePassword/UpdatePassword.tsx b/client/src/protoFleet/features/auth/pages/UpdatePassword/UpdatePassword.tsx new file mode 100644 index 000000000..bdae8c12c --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/UpdatePassword/UpdatePassword.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuth } from "@/protoFleet/api/useAuth"; +import { UpdatePasswordForm, UpdatePasswordSuccess } from "@/protoFleet/features/auth/components"; +import { useSetTemporaryPassword, useTemporaryPassword } from "@/protoFleet/store"; +import { pushToast, STATUSES as TOAST_STATUSES } from "@/shared/features/toaster"; +import { useNavigate } from "@/shared/hooks/useNavigate"; + +const UpdatePassword = () => { + const navigate = useNavigate(); + const { updatePassword } = useAuth(); + const temporaryPassword = useTemporaryPassword(); + const setTemporaryPassword = useSetTemporaryPassword(); + + const [errorMsg, setErrorMsg] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + // Redirect to login if no temporary password is available on mount + useEffect(() => { + if (!temporaryPassword) { + pushToast({ + message: "Session expired. Please log in again.", + status: TOAST_STATUSES.error, + }); + navigate("/"); + } + // Only check on initial mount, not when temporaryPassword changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Clear temporary password on unmount for security + useEffect(() => { + return () => { + setTemporaryPassword(null); + }; + }, [setTemporaryPassword]); + + const handleUpdatePassword = useCallback( + (newPassword: string, _confirmPassword: string) => { + // Form handles validation (password length, match, weak password warning) + setIsSubmitting(true); + setErrorMsg(""); + + updatePassword({ + currentPassword: temporaryPassword!, + newPassword, + onSuccess: () => { + setTemporaryPassword(null); + setIsSuccess(true); + pushToast({ + message: "Password updated", + status: TOAST_STATUSES.success, + }); + }, + onError: (error: string) => { + setErrorMsg(error || "Failed to update password. Please try again."); + }, + onFinally: () => { + setIsSubmitting(false); + }, + }); + }, + [temporaryPassword, updatePassword, setTemporaryPassword], + ); + + const handleLogin = useCallback(() => { + navigate("/"); + }, [navigate]); + + if (isSuccess) { + return ; + } + + return ( + setErrorMsg("")} + /> + ); +}; + +export default UpdatePassword; diff --git a/client/src/protoFleet/features/auth/pages/UpdatePassword/index.ts b/client/src/protoFleet/features/auth/pages/UpdatePassword/index.ts new file mode 100644 index 000000000..f7a6dd53b --- /dev/null +++ b/client/src/protoFleet/features/auth/pages/UpdatePassword/index.ts @@ -0,0 +1 @@ +export { default } from "./UpdatePassword"; diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.stories.tsx b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.stories.tsx new file mode 100644 index 000000000..78f50f816 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.stories.tsx @@ -0,0 +1,220 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ChartWidget from "./ChartWidget"; +import LineChart from "@/protoFleet/components/LineChart"; +import { ChartData } from "@/shared/components/LineChart/types"; + +// Generate sample chart data +const generateSampleData = (): ChartData[] => { + const now = Date.now(); + const data: ChartData[] = []; + const baseValue = 450; + + // Generate 24 hours of data points (every hour) + for (let i = 0; i < 24; i++) { + const timestamp = now - (23 - i) * 60 * 60 * 1000; + const variation = Math.sin(i / 3) * 50 + Math.random() * 20; + + data.push({ + datetime: timestamp, + totalHashrate: baseValue + variation, + }); + } + + return data; +}; + +const meta: Meta = { + title: "Proto Fleet/Dashboard/ChartWidget", + component: ChartWidget, + parameters: { + layout: "centered", + docs: { + description: { + component: + "ChartWidget is a container component that displays a title, optional stats, and chart content. It can display a single stat or multiple stats in a grid layout.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + statsSize: { + control: "select", + options: ["small", "medium", "large"], + description: "Size of the stats display", + }, + statsGrid: { + control: "text", + description: "Tailwind grid class for stats layout", + }, + className: { + control: "text", + description: "Additional CSS classes", + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const SingleStat: Story = { + args: { + stats: { + label: "Current", + value: "230.2", + units: "TH/s", + }, + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const MultipleStats: Story = { + args: { + stats: [ + { + label: "Hashrate", + value: "230.2", + units: "TH/s", + }, + { + label: "Efficiency", + value: "22.5", + units: "J/TH", + }, + { + label: "Temperature", + value: "65°C", + units: "Average", + }, + ], + statsGrid: "grid-cols-3", + statsGap: "gap-x-8", + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const NoStats: Story = { + args: {}, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const PercentageStats: Story = { + args: { + stats: [ + { + label: "Overall", + value: "85%", + units: "Utilization", + }, + { + label: "Active", + value: "178", + units: "miners", + }, + { + label: "Offline", + value: "22", + units: "miners", + }, + ], + statsGrid: "grid-cols-3", + statsSize: "medium", + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; + +export const SmallStats: Story = { + args: { + stats: [ + { + label: "Min", + value: "220.1", + units: "TH/s", + }, + { + label: "Avg", + value: "230.2", + units: "TH/s", + }, + { + label: "Max", + value: "245.8", + units: "TH/s", + }, + ], + statsGrid: "grid-cols-3", + statsSize: "small", + }, + render: (args) => { + const sampleData = generateSampleData(); + return ( + + + + ); + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.test.tsx b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.test.tsx new file mode 100644 index 000000000..5d7bac8ef --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import ChartWidget from "./ChartWidget"; + +describe("ChartWidget", () => { + it("renders single stat", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("Hashrate")).toBeInTheDocument(); + expect(screen.getByText("230.2")).toBeInTheDocument(); + expect(screen.getByText("TH/s")).toBeInTheDocument(); + }); + + it("renders multiple stats", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("Hashrate")).toBeInTheDocument(); + expect(screen.getByText("230.2")).toBeInTheDocument(); + expect(screen.getByText("TH/s")).toBeInTheDocument(); + expect(screen.getByText("Efficiency")).toBeInTheDocument(); + expect(screen.getByText("67.0")).toBeInTheDocument(); + expect(screen.getByText("J/TH")).toBeInTheDocument(); + expect(screen.getByText("Temperature")).toBeInTheDocument(); + expect(screen.getByText("65°")).toBeInTheDocument(); + expect(screen.getByText("Average")).toBeInTheDocument(); + }); + + it("renders without stats", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("Chart Content")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + render( + +
Mock Chart
+
, + ); + + expect(screen.getByTestId("chart-content")).toBeInTheDocument(); + expect(screen.getByText("Mock Chart")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render( + +
Chart Content
+
, + ); + + const widget = container.firstChild as HTMLElement; + expect(widget).toHaveClass("custom-class"); + expect(widget).toHaveClass("rounded-xl"); + expect(widget).toHaveClass("bg-surface-base"); + expect(widget).toHaveClass("p-10"); + }); + + it("handles percentage values with special formatting", () => { + render( + +
Chart Content
+
, + ); + + expect(screen.getByText("85%")).toBeInTheDocument(); + expect(screen.getByText("Current")).toBeInTheDocument(); + }); + + it("uses custom stats configuration", () => { + const { container } = render( + +
Chart Content
+
, + ); + + // Check that Stats component is rendered (would have the grid class) + const statsContainer = container.querySelector(".grid-cols-2"); + expect(statsContainer).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.tsx b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.tsx new file mode 100644 index 000000000..a4e8577a5 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/ChartWidget.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from "react"; +import clsx from "clsx"; +import type { StatProps } from "@/shared/components/Stat"; +import Stats from "@/shared/components/Stats"; + +type ChartWidgetStat = Omit; + +type ChartWidgetProps = { + stats?: ChartWidgetStat | ChartWidgetStat[]; + children: ReactNode; + className?: string; + statsGrid?: string; + statsGap?: string; + statsPadding?: string; + statsSize?: StatProps["size"]; +}; + +const ChartWidget = ({ + stats, + children, + className, + statsGrid = "grid-cols-1", + statsGap = "gap-4", + statsPadding = "pb-6", + statsSize = "large", +}: ChartWidgetProps) => { + // Normalize stats to always be an array + const statsArray = stats ? (Array.isArray(stats) ? stats : [stats]) : []; + + return ( +
+
+ {statsArray.length > 0 && ( + + )} +
+
{children}
+
+ ); +}; + +export default ChartWidget; diff --git a/client/src/protoFleet/features/dashboard/components/ChartWidget/index.ts b/client/src/protoFleet/features/dashboard/components/ChartWidget/index.ts new file mode 100644 index 000000000..ee89f922f --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/ChartWidget/index.ts @@ -0,0 +1,3 @@ +import ChartWidget from "./ChartWidget"; + +export default ChartWidget; diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.test.tsx b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.test.tsx new file mode 100644 index 000000000..63e4b0f0b --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { EfficiencyPanel } from "./EfficiencyPanel"; +import { + AggregatedValueSchema, + AggregationType, + MeasurementType, + type Metric, + MetricSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +// Helper function to create mock Metric with device count +const createMockMetric = (avgValue: number, deviceCount: number): Metric => { + return create(MetricSchema, { + measurementType: MeasurementType.EFFICIENCY, + openTime: { + seconds: BigInt(Math.floor(Date.now() / 1000)), + nanos: 0, + }, + aggregatedValues: [ + create(AggregatedValueSchema, { + aggregationType: AggregationType.AVERAGE, + value: avgValue, + }), + ], + deviceCount, + }); +}; + +describe("EfficiencyPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows subtitle when not all miners are reporting", () => { + const metrics = [createMockMetric(25.5, 3)]; + + render(); + + expect(screen.getByText("3 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("hides subtitle when all miners are reporting", () => { + const metrics = [createMockMetric(25.5, 5)]; + + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("hides subtitle when device count is null", () => { + // No metrics, so device count will be null + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("shows subtitle with zero miners reporting", () => { + const metrics = [createMockMetric(0, 0)]; + + render(); + + expect(screen.getByText("0 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("uses max device count across buckets, not the last bucket", () => { + // Arrange — first bucket has 5 devices, second (incomplete) bucket has only 3 + const metrics = [createMockMetric(25.5, 5), createMockMetric(24.0, 3)]; + + // Act + render(); + + // Assert — subtitle should reflect the max (5), not the last bucket (3) + expect(screen.getByText("5 of 7 miners reporting")).toBeInTheDocument(); + }); + + it("renders loading state without subtitle", () => { + // undefined = not loaded yet (loading state) + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.tsx b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.tsx new file mode 100644 index 000000000..48567b063 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/EfficiencyPanel.tsx @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { transformEfficiencyMetricsToChartData } from "./utils"; +import { type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import LineChart from "@/protoFleet/components/LineChart"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { padChartDataWithNulls } from "@/protoFleet/features/dashboard/utils/chartDataPadding"; +import { getMinerCountSubtitle } from "@/protoFleet/features/dashboard/utils/minerCountSubtitle"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface EfficiencyPanelProps { + duration: FleetDuration; + /** Efficiency metrics — undefined = not loaded yet, empty array = loaded but no data */ + metrics: Metric[] | undefined; + /** Total miner count for "X of Y miners reporting" subtitle */ + totalMiners: number; +} + +export function EfficiencyPanel({ duration, metrics, totalMiners }: EfficiencyPanelProps) { + // Transform metrics data to chart format (merging already done by store selectors) + const chartData = useMemo(() => { + if (metrics === undefined) return undefined; // Not loaded yet + if (metrics.length === 0) return null; // Loaded but no data + + const transformedData = transformEfficiencyMetricsToChartData(metrics); + + // Pad with null values for the full duration + return padChartDataWithNulls(transformedData, duration); + }, [metrics, duration]); + + // Get the latest efficiency value for the stat display + const currentEfficiency = useMemo(() => { + if (chartData === undefined) return undefined; // Not loaded yet + if (chartData === null || chartData.length === 0) return null; // Loaded but no data + return chartData[chartData.length - 1]?.efficiency ?? null; + }, [chartData]); + + // Use max device count across all buckets — the last bucket may be incomplete + // and fluctuate as new data arrives. + const deviceCount = useMemo(() => { + if (metrics === undefined) return undefined; + if (metrics.length === 0) return null; + return Math.max(...metrics.map((m) => m.deviceCount)); + }, [metrics]); + + // Show loading skeleton while data hasn't loaded yet + if (metrics === undefined) { + const stat = { + label: "Efficiency", + value: undefined, + units: "", + }; + + return ( + + + + ); + } + + // Handle no data case - still show the widget with header but no chart + if (!chartData || chartData.length === 0) { + const stat = { + label: "Efficiency", + value: "No data", + units: "", + }; + + return {null}; + } + + const efficiencyDisplayValue = + currentEfficiency !== null && currentEfficiency !== undefined ? currentEfficiency.toFixed(1) : "N/A"; + + const subtitle = getMinerCountSubtitle(deviceCount ?? null, totalMiners); + const stat = { + label: "Efficiency", + value: efficiencyDisplayValue, + units: "J/TH", + subtitle, + tooltipContent: subtitle ? "Some devices do not make this data available to Proto Fleet." : undefined, + }; + + return ( + + + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/index.ts b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/index.ts new file mode 100644 index 000000000..71f505586 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/index.ts @@ -0,0 +1 @@ +export { EfficiencyPanel } from "./EfficiencyPanel"; diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.test.ts new file mode 100644 index 000000000..2671f2d8a --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { transformEfficiencyMetricsToChartData } from "./utils"; +import { MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { createMockMetric } from "@/protoFleet/features/dashboard/utils/createMockMetric"; + +describe("transformEfficiencyMetricsToChartData", () => { + it("returns empty array for empty metrics", () => { + expect(transformEfficiencyMetricsToChartData([])).toEqual([]); + }); + + it("keeps already-normalized values unchanged", () => { + const metrics = [createMockMetric(MeasurementType.EFFICIENCY, 24.4, 1000)]; + const result = transformEfficiencyMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, efficiency: 24.4 }]); + }); + + it("normalizes large over-converted values back to J/TH", () => { + const metrics = [createMockMetric(MeasurementType.EFFICIENCY, 24.4e12, 1000)]; + const result = transformEfficiencyMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, efficiency: 24.4 }]); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.ts b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.ts new file mode 100644 index 000000000..45f48b958 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/EfficiencyPanel/utils.ts @@ -0,0 +1,28 @@ +import { AggregationType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { normalizeEfficiencyToJTH } from "@/protoFleet/features/dashboard/utils/metricNormalization"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +/** + * Transform efficiency metrics from the API to chart data format + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Array of ChartData objects for LineChart + */ +export function transformEfficiencyMetricsToChartData(metrics: Metric[]): ChartData[] { + if (!metrics || metrics.length === 0) { + return []; + } + + return metrics.map((metric) => { + // Find the AVERAGE aggregation value, default to the first value if not found + const avgValue = + metric.aggregatedValues.find((agg) => agg.aggregationType === AggregationType.AVERAGE)?.value ?? + metric.aggregatedValues[0]?.value ?? + 0; + const normalizedEfficiency = normalizeEfficiencyToJTH(avgValue); + + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, // Convert seconds to milliseconds + efficiency: normalizedEfficiency, + }; + }); +} diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.stories.tsx b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.stories.tsx new file mode 100644 index 000000000..323fad969 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.stories.tsx @@ -0,0 +1,136 @@ +import { BrowserRouter } from "react-router-dom"; +import type { Meta, StoryObj } from "@storybook/react"; +import FleetHealth from "./FleetHealth"; + +const meta: Meta = { + title: "Proto Fleet/Dashboard/FleetHealth", + component: FleetHealth, + parameters: { + withRouter: false, + layout: "centered", + docs: { + description: { + component: "Displays fleet health statistics with a composition bar visualization", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + fleetSize: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Total number of miners in the fleet", + }, + healthyMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of healthy/active miners", + }, + needsAttentionMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of miners needing attention (ERROR or AUTHENTICATION_NEEDED)", + }, + offlineMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of offline miners", + }, + sleepingMiners: { + control: { type: "number", min: 0, max: 1000, step: 1 }, + description: "Number of sleeping/inactive miners", + }, + }, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + fleetSize: 200, + healthyMiners: 178, + needsAttentionMiners: 15, + offlineMiners: 5, + sleepingMiners: 2, + }, +}; + +export const AllHealthy: Story = { + args: { + fleetSize: 100, + healthyMiners: 100, + needsAttentionMiners: 0, + offlineMiners: 0, + sleepingMiners: 0, + }, +}; + +export const MostlyHealthy: Story = { + args: { + fleetSize: 100, + healthyMiners: 85, + needsAttentionMiners: 5, + offlineMiners: 5, + sleepingMiners: 5, + }, +}; + +export const Warning: Story = { + args: { + fleetSize: 100, + healthyMiners: 70, + needsAttentionMiners: 15, + offlineMiners: 10, + sleepingMiners: 5, + }, +}; + +export const Critical: Story = { + args: { + fleetSize: 100, + healthyMiners: 30, + needsAttentionMiners: 40, + offlineMiners: 20, + sleepingMiners: 10, + }, +}; + +export const SmallFleet: Story = { + args: { + fleetSize: 10, + healthyMiners: 7, + needsAttentionMiners: 1, + offlineMiners: 1, + sleepingMiners: 1, + }, +}; + +export const LargeFleet: Story = { + args: { + fleetSize: 1000, + healthyMiners: 850, + needsAttentionMiners: 80, + offlineMiners: 50, + sleepingMiners: 20, + }, +}; + +export const Loading: Story = { + args: { + // All props undefined to show loading state + }, +}; + +export const PartialLoading: Story = { + args: { + fleetSize: 100, + healthyMiners: 70, + // needsAttentionMiners, offlineMiners, and sleepingMiners undefined + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.test.tsx b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.test.tsx new file mode 100644 index 000000000..03af6d604 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.test.tsx @@ -0,0 +1,275 @@ +import React from "react"; +import { BrowserRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import FleetHealth from "./FleetHealth"; + +describe("FleetHealth", () => { + const renderWithRouter = (component: React.ReactElement) => { + return render({component}); + }; + + it("renders correct stats when all miners are healthy", () => { + renderWithRouter( + , + ); + + // Check title label + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check percentages + expect(screen.getByText("100%")).toBeInTheDocument(); // Healthy + + // Check counts - "100 miners" appears twice (header and healthy column) + const healthyCount = screen.getAllByText("100 miners"); + expect(healthyCount).toHaveLength(2); // One in header, one in healthy column + + const zeroMiners = screen.getAllByText("0 miners"); + expect(zeroMiners).toHaveLength(3); // Needs Attention, Offline, and Sleeping columns + + // Check that legend is present + const healthyTexts = screen.getAllByText("Healthy"); + expect(healthyTexts.length).toBeGreaterThan(0); + const needsAttentionTexts = screen.getAllByText("Needs Attention"); + expect(needsAttentionTexts.length).toBeGreaterThan(0); + const offlineTexts = screen.getAllByText("Offline"); + expect(offlineTexts.length).toBeGreaterThan(0); + const sleepingTexts = screen.getAllByText("Sleeping"); + expect(sleepingTexts.length).toBeGreaterThan(0); + + // Check CompositionBar is rendered + const progressBars = screen.getAllByRole("progressbar"); + expect(progressBars.length).toBeGreaterThan(0); + }); + + it("renders correct stats with mixed fleet health", () => { + renderWithRouter( + , + ); + + // Check miner count + expect(screen.getByText("200 miners")).toBeInTheDocument(); + + // Check percentages (85% + 9% + 4% + 2% = 100%) + expect(screen.getByText("85%")).toBeInTheDocument(); // Healthy: 170/200 = 85% + expect(screen.getByText("9%")).toBeInTheDocument(); // Needs Attention: 18/200 = 9% + expect(screen.getByText("4%")).toBeInTheDocument(); // Offline: 8/200 = 4% + expect(screen.getByText("2%")).toBeInTheDocument(); // Sleeping: 4/200 = 2% + + // Check miner counts + expect(screen.getByText("170 miners")).toBeInTheDocument(); + expect(screen.getByText("18 miners")).toBeInTheDocument(); + expect(screen.getByText("8 miners")).toBeInTheDocument(); + expect(screen.getByText("4 miners")).toBeInTheDocument(); + }); + + it("renders stats for fleet with moderate health distribution", () => { + renderWithRouter( + , + ); + + // Check title label + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check percentages (all unique values) + expect(screen.getByText("60%")).toBeInTheDocument(); // Healthy: 60/100 = 60% + expect(screen.getByText("20%")).toBeInTheDocument(); // Needs Attention: 20/100 = 20% + expect(screen.getByText("12%")).toBeInTheDocument(); // Offline: 12/100 = 12% + expect(screen.getByText("8%")).toBeInTheDocument(); // Sleeping: 8/100 = 8% + }); + + it("renders stats for fleet with critical health distribution", () => { + renderWithRouter( + , + ); + + // Check title label + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check percentages (all unique values) + expect(screen.getByText("15%")).toBeInTheDocument(); // Healthy: 15/100 = 15% + expect(screen.getByText("50%")).toBeInTheDocument(); // Needs Attention: 50/100 = 50% + expect(screen.getByText("25%")).toBeInTheDocument(); // Offline: 25/100 = 25% + expect(screen.getByText("10%")).toBeInTheDocument(); // Sleeping: 10/100 = 10% + }); + + it("handles division by zero when fleet size is 0", () => { + renderWithRouter( + , + ); + + // Should render without errors + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // All percentages should be 0% + const zeroPercents = screen.getAllByText("0%"); + expect(zeroPercents).toHaveLength(4); // Healthy, Needs Attention, Offline, Sleeping + + // All miner counts should be 0 miners (4 in columns, 1 in header = 5 total) + const zeroMinerCounts = screen.getAllByText("0 miners"); + expect(zeroMinerCounts).toHaveLength(5); + }); + + it("renders loading state when miner counts are undefined", () => { + renderWithRouter(); + + // Should render skeleton bars instead of values + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // Check that all stat labels are present but with skeleton bars + const healthyTexts = screen.getAllByText("Healthy"); + expect(healthyTexts.length).toBeGreaterThan(0); + const needsAttentionTexts = screen.getAllByText("Needs Attention"); + expect(needsAttentionTexts.length).toBeGreaterThan(0); + const offlineTexts = screen.getAllByText("Offline"); + expect(offlineTexts.length).toBeGreaterThan(0); + const sleepingTexts = screen.getAllByText("Sleeping"); + expect(sleepingTexts.length).toBeGreaterThan(0); + + // Skeleton bars should be present: + // - 5 for stat values (Your fleet + 4 health categories) + // - 5 for composition bar area (1 bar + 4 legend items) + const skeletonBars = screen.getAllByTestId("skeleton-bar"); + expect(skeletonBars.length).toBe(10); + }); + + it("renders full loading state when some props are undefined", () => { + renderWithRouter( + , + ); + + // Check title label is present + expect(screen.getByText("Your fleet")).toBeInTheDocument(); + + // When ANY prop is undefined, show full loading skeleton + // This provides consistent UX rather than showing partial/incomplete data + const skeletonBars = screen.getAllByTestId("skeleton-bar"); + expect(skeletonBars.length).toBe(10); // Full loading state (5 stats + 5 composition bar area) + + // Defined values should NOT be shown (we're in loading state) + expect(screen.queryByText("70%")).not.toBeInTheDocument(); + expect(screen.queryByText("70 miners")).not.toBeInTheDocument(); + }); + + it("renders legend with correct color indicators", () => { + const { container } = renderWithRouter( + , + ); + + // Check legend items + const healthyTexts = screen.getAllByText("Healthy"); + expect(healthyTexts.length).toBeGreaterThan(0); + const needsAttentionTexts = screen.getAllByText("Needs Attention"); + expect(needsAttentionTexts.length).toBeGreaterThan(0); + const offlineTexts = screen.getAllByText("Offline"); + expect(offlineTexts.length).toBeGreaterThan(0); + const sleepingTexts = screen.getAllByText("Sleeping"); + expect(sleepingTexts.length).toBeGreaterThan(0); + + // Check that the triangle SVG exists for needs attention + const svgTriangle = container.querySelector("svg"); + expect(svgTriangle).toBeInTheDocument(); + + // Check color indicators + const greenIndicators = container.querySelectorAll(".bg-core-primary-fill"); + const redIndicators = container.querySelectorAll(".fill-intent-critical-fill, .text-intent-critical-fill"); + const accentIndicators = container.querySelectorAll(".bg-core-accent-fill"); + const primaryIndicators = container.querySelectorAll(".bg-core-primary-20"); + + expect(greenIndicators.length).toBeGreaterThan(0); // Healthy + expect(redIndicators.length).toBeGreaterThan(0); // Needs Attention + expect(accentIndicators.length).toBeGreaterThan(0); // Offline + expect(primaryIndicators.length).toBeGreaterThan(0); // Sleeping + }); + + it("handles pluralization correctly for singular miner", () => { + renderWithRouter( + , + ); + + // Check singular form - should appear in title and Needs Attention stat + const oneMinerText = screen.getAllByText(/1 miner/); + expect(oneMinerText.length).toBe(2); // Once in title, once in Needs Attention stat + }); + + it("handles pluralization correctly for multiple miners", () => { + renderWithRouter( + , + ); + + // Check plural form + expect(screen.getByText("50 miners")).toBeInTheDocument(); + expect(screen.getByText("30 miners")).toBeInTheDocument(); + expect(screen.getByText("10 miners")).toBeInTheDocument(); + expect(screen.getByText("7 miners")).toBeInTheDocument(); + expect(screen.getByText("3 miners")).toBeInTheDocument(); + }); + + it("renders mdash for all stats when counts are null (loaded but no data)", () => { + renderWithRouter( + , + ); + + // Should show mdash (\u2014) for each stat, not skeleton bars + const mdashes = screen.getAllByText("\u2014"); + expect(mdashes).toHaveLength(5); // title + 4 categories + + // No skeleton bars should be present + expect(screen.queryByTestId("skeleton-bar")).not.toBeInTheDocument(); + + // No composition bar or legend should be shown + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("renders mdash state when some counts are null", () => { + renderWithRouter( + , + ); + + // Should show mdash state, not skeleton or data + const mdashes = screen.getAllByText("\u2014"); + expect(mdashes.length).toBeGreaterThan(0); + expect(screen.queryByTestId("skeleton-bar")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.tsx b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.tsx new file mode 100644 index 000000000..dd49d4e9a --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/FleetHealth.tsx @@ -0,0 +1,290 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { create } from "@bufbuild/protobuf"; +import ChartWidget from "../ChartWidget/ChartWidget"; +import { MinerListFilterSchema } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; +import { DeviceStatus } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { encodeFilterToURL } from "@/protoFleet/features/fleetManagement/utils/filterUrlParams"; +import { Triangle } from "@/shared/assets/icons"; +import CompositionBar, { type Segment } from "@/shared/components/CompositionBar"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +const FleetHealthSkeleton = ({ title = "Your fleet" }: { title?: string }) => ( + +
+
+ +
+
+ + + + +
+
+
+); + +/** undefined = still loading (skeleton), null = loaded but no data (show mdash), number = show value */ +type MinerCount = number | null | undefined; + +interface FleetHealthProps { + fleetSize?: MinerCount; + healthyMiners?: MinerCount; + needsAttentionMiners?: MinerCount; + offlineMiners?: MinerCount; + sleepingMiners?: MinerCount; + /** Override the default "Your fleet" title (e.g., group name) */ + title?: string; + /** Extra URL search params to append to miner list links (e.g., "group=123") */ + extraFilterParams?: string; + /** Link URL for the total miners count (e.g., "/miners?group=123") */ + totalMinersLink?: string; +} + +const FleetHealth = ({ + fleetSize, + healthyMiners, + needsAttentionMiners, + offlineMiners, + sleepingMiners, + title = "Your fleet", + extraFilterParams, + totalMinersLink, +}: FleetHealthProps) => { + // undefined = still loading (show skeleton), null = loaded but no data (show mdash) + const isLoading = + fleetSize === undefined || + healthyMiners === undefined || + needsAttentionMiners === undefined || + offlineMiners === undefined || + sleepingMiners === undefined; + + // When any count is null, we've finished loading but have no data (e.g. API error) + const hasNoData = + fleetSize === null || + healthyMiners === null || + needsAttentionMiners === null || + offlineMiners === null || + sleepingMiners === null; + + // Create enhanced segments with filter URLs + // Note: useMemo must be called unconditionally (Rules of Hooks) + const segmentsWithFilters = useMemo(() => { + // Return empty array during loading or no-data states to satisfy hook requirements + if (isLoading || hasNoData) return []; + + const totalMiners = fleetSize || 1; // prevent division by zero + + // Define segments with their filter configurations + const segmentConfigs = [ + { + name: "Healthy", + status: "OK" as Segment["status"], + count: healthyMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.ONLINE], + }), + clickable: false, // Healthy is not clickable + }, + { + name: "Needs Attention", + status: "CRITICAL" as Segment["status"], + count: needsAttentionMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [ + DeviceStatus.ERROR, + DeviceStatus.NEEDS_MINING_POOL, + DeviceStatus.UPDATING, + DeviceStatus.REBOOT_REQUIRED, + ], + }), + clickable: true, + }, + { + name: "Offline", + status: "NA" as Segment["status"], + count: offlineMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.OFFLINE], + }), + clickable: true, + }, + { + name: "Sleeping", + status: "WARNING" as Segment["status"], + count: sleepingMiners, + filter: create(MinerListFilterSchema, { + deviceStatus: [DeviceStatus.INACTIVE], + }), + clickable: true, + }, + ]; + + // Add filter URL and percentage to each segment + return segmentConfigs.map((segment) => { + const params = encodeFilterToURL(segment.filter); + if (extraFilterParams) { + new URLSearchParams(extraFilterParams).forEach((value, key) => params.set(key, value)); + } + return { + ...segment, + filterUrl: `/miners?${params.toString()}`, + percentage: segment.count !== undefined ? Math.round((segment.count / totalMiners) * 100) : undefined, + }; + }); + }, [ + fleetSize, + healthyMiners, + needsAttentionMiners, + offlineMiners, + sleepingMiners, + isLoading, + hasNoData, + extraFilterParams, + ]); + + // Extract basic segments for CompositionBar (without extra props) + const segments = useMemo( + () => + segmentsWithFilters.map(({ name, status, count }) => ({ + name, + status, + count, + })), + [segmentsWithFilters], + ); + + // Derive stats from segments + const stats = useMemo( + () => + segmentsWithFilters.map((segment) => { + // Pluralization helper + const minerText = segment.count === 1 ? "miner" : "miners"; + + // Determine if this segment should have a link + const shouldHaveLink = segment.clickable && (segment.count ?? 0) > 0; + + return { + label: segment.name, + value: segment.percentage !== undefined ? `${segment.percentage}%` : undefined, + text: + segment.count !== undefined ? ( + shouldHaveLink ? ( + + {segment.count} {minerText} + + ) : ( + <> + {segment.count} {minerText} + + ) + ) : undefined, + }; + }), + [segmentsWithFilters], + ); + + // Create the title stat for ChartWidget title area + const titleStat = useMemo( + () => ({ + label: title, + value: + fleetSize !== undefined + ? totalMinersLink + ? `${fleetSize}\u200B` + : `${fleetSize} ${fleetSize === 1 ? "miner" : "miners"}` + : undefined, + text: + totalMinersLink && fleetSize !== undefined ? ( + + View all + + ) : undefined, + }), + [fleetSize, title, totalMinersLink], + ); + + if (isLoading) { + return ; + } + + if (hasNoData) { + return ( + + {null} + + ); + } + + return ( + +
+ {/* Composition Bar */} +
+ +
+ + {/* Legend */} +
+
+ + Healthy +
+
+ + Needs Attention +
+
+ + Offline +
+
+ + Sleeping +
+
+
+
+ ); +}; + +export default FleetHealth; diff --git a/client/src/protoFleet/features/dashboard/components/FleetHealth/index.ts b/client/src/protoFleet/features/dashboard/components/FleetHealth/index.ts new file mode 100644 index 000000000..387200f26 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/FleetHealth/index.ts @@ -0,0 +1,3 @@ +import FleetHealth from "./FleetHealth"; + +export default FleetHealth; diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/HashratePanel.tsx b/client/src/protoFleet/features/dashboard/components/HashratePanel/HashratePanel.tsx new file mode 100644 index 000000000..bc6df0ccb --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/HashratePanel.tsx @@ -0,0 +1,88 @@ +import { useMemo } from "react"; +import { transformHashrateMetricsWithUnits } from "./utils"; +import { type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import LineChart from "@/protoFleet/components/LineChart"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { padChartDataWithNulls } from "@/protoFleet/features/dashboard/utils/chartDataPadding"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface HashratePanelProps { + duration: FleetDuration; + /** Hashrate metrics — undefined = not loaded yet, empty array = loaded but no data */ + metrics: Metric[] | undefined; +} + +export function HashratePanel({ duration, metrics }: HashratePanelProps) { + // Transform metrics data to chart format with consistent unit scaling + // Both chart data and unit are derived together to ensure consistency + const { chartData, hashrateUnits } = useMemo(() => { + if (metrics === undefined) return { chartData: undefined, hashrateUnits: "" }; // Not loaded yet + if (metrics.length === 0) return { chartData: null, hashrateUnits: "" }; // Loaded but no data + + const { chartData: transformedData, unit } = transformHashrateMetricsWithUnits(metrics); + + // Pad with null values for the full duration + return { + chartData: padChartDataWithNulls(transformedData, duration), + hashrateUnits: unit, + }; + }, [metrics, duration]); + + // Get the latest hashrate value from already-transformed chart data + const currentHashrate = useMemo(() => { + if (chartData === undefined) return undefined; // Not loaded yet + if (chartData === null || chartData.length === 0) return null; // Loaded but no data + return chartData[chartData.length - 1]?.hashrate ?? null; + }, [chartData]); + + // Show loading skeleton while data hasn't loaded yet + if (metrics === undefined) { + const stat = { + label: "Hashrate", + value: undefined, + units: "", + }; + + return ( + + + + ); + } + + // Handle no data case - still show the widget with header but no chart + if (!chartData || chartData.length === 0) { + const stat = { + label: "Hashrate", + value: "No data", + units: "", + }; + + return {null}; + } + + // Format the current hashrate for display + const hashrateDisplayValue = + currentHashrate !== null && currentHashrate !== undefined ? currentHashrate.toFixed(1) : "N/A"; + + const stat = { + label: "Hashrate", + value: hashrateDisplayValue, + units: hashrateUnits, + }; + + return ( + + + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/index.ts b/client/src/protoFleet/features/dashboard/components/HashratePanel/index.ts new file mode 100644 index 000000000..8e97416c3 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/index.ts @@ -0,0 +1 @@ +export { HashratePanel } from "./HashratePanel"; diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.test.ts new file mode 100644 index 000000000..e5547fca2 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { transformHashrateMetricsToChartData, transformHashrateMetricsWithUnits } from "./utils"; +import { MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { createMockMetric } from "@/protoFleet/features/dashboard/utils/createMockMetric"; + +describe("transformHashrateMetricsToChartData", () => { + it("should return empty array for empty metrics", () => { + expect(transformHashrateMetricsToChartData([])).toEqual([]); + }); + + it("should transform metrics to chart data format", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 500, 1000)]; + const result = transformHashrateMetricsToChartData(metrics); + expect(result).toEqual([{ datetime: 1000000, hashrate: 500 }]); + }); + + it("should normalize raw H/s values into TH/S", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 500e12, 1000)]; + const result = transformHashrateMetricsToChartData(metrics); + expect(result).toEqual([{ datetime: 1000000, hashrate: 500 }]); + }); +}); + +describe("transformHashrateMetricsWithUnits", () => { + it("should return TH/S for empty metrics", () => { + const result = transformHashrateMetricsWithUnits([]); + expect(result).toEqual({ chartData: [], unit: "TH/S" }); + }); + + it("should use TH/S when max value is at threshold (1000)", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 1000, 1000)]; + const result = transformHashrateMetricsWithUnits(metrics); + expect(result.unit).toBe("TH/S"); + expect(result.chartData[0].hashrate).toBe(1000); + }); + + it("should convert to PH/S when max value exceeds threshold (1001)", () => { + const metrics = [createMockMetric(MeasurementType.HASHRATE, 1001, 1000)]; + const result = transformHashrateMetricsWithUnits(metrics); + expect(result.unit).toBe("PH/S"); + expect(result.chartData[0].hashrate).toBe(1.001); + }); + + it("should convert all values when any value exceeds threshold", () => { + const metrics = [ + createMockMetric(MeasurementType.HASHRATE, 500, 1000), + createMockMetric(MeasurementType.HASHRATE, 2000, 2000), + ]; + const result = transformHashrateMetricsWithUnits(metrics); + expect(result.unit).toBe("PH/S"); + expect(result.chartData[0].hashrate).toBe(0.5); + expect(result.chartData[1].hashrate).toBe(2); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.ts b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.ts new file mode 100644 index 000000000..8c3463865 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/HashratePanel/utils.ts @@ -0,0 +1,65 @@ +import { AggregationType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { normalizeHashrateToTHs } from "@/protoFleet/features/dashboard/utils/metricNormalization"; +import type { ChartData } from "@/shared/components/LineChart/types"; +import { TH_TO_PH_DIVISOR, TH_TO_PH_THRESHOLD } from "@/shared/utils/utility"; + +/** + * Transform hashrate metrics from the API to chart data format + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Array of ChartData objects for LineChart + */ +export function transformHashrateMetricsToChartData(metrics: Metric[]): ChartData[] { + if (!metrics || metrics.length === 0) { + return []; + } + + return metrics.map((metric) => { + // Find the AVERAGE aggregation value, default to the first value if not found + const avgValue = + metric.aggregatedValues.find((agg) => agg.aggregationType === AggregationType.AVERAGE)?.value ?? + metric.aggregatedValues[0]?.value ?? + 0; + const normalizedHashrate = normalizeHashrateToTHs(avgValue, metric.deviceCount); + + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, // Convert seconds to milliseconds + hashrate: normalizedHashrate, + }; + }); +} + +/** + * Transform hashrate metrics to chart data with appropriate unit scaling. + * Automatically converts TH/S to PH/S when values exceed 1000 TH/S. + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Object containing scaled chart data and the appropriate unit string + */ +export function transformHashrateMetricsWithUnits(metrics: Metric[]): { + chartData: ChartData[]; + unit: string; +} { + const rawData = transformHashrateMetricsToChartData(metrics); + + if (rawData.length === 0) { + return { chartData: [], unit: "TH/S" }; + } + + // Find max value to determine if we should use PH/S + const maxValue = Math.max(...rawData.map((d) => d.hashrate ?? 0)); + + if (maxValue > TH_TO_PH_THRESHOLD) { + // Convert all values to PH/S + return { + chartData: rawData.map((d) => ({ + ...d, + hashrate: d.hashrate !== null ? d.hashrate / TH_TO_PH_DIVISOR : null, + })), + unit: "PH/S", + }; + } + + return { + chartData: rawData, + unit: "TH/S", + }; +} diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.test.tsx b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.test.tsx new file mode 100644 index 000000000..4c6e0c252 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { PowerPanel } from "./PowerPanel"; +import { + AggregatedValueSchema, + AggregationType, + MeasurementType, + type Metric, + MetricSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +// Helper function to create mock Metric with device count +const createMockMetric = (avgValue: number, deviceCount: number): Metric => { + return create(MetricSchema, { + measurementType: MeasurementType.POWER, + openTime: { + seconds: BigInt(Math.floor(Date.now() / 1000)), + nanos: 0, + }, + aggregatedValues: [ + create(AggregatedValueSchema, { + aggregationType: AggregationType.AVERAGE, + value: avgValue, + }), + ], + deviceCount, + }); +}; + +describe("PowerPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows subtitle when not all miners are reporting", () => { + const metrics = [createMockMetric(1500, 3)]; + + render(); + + expect(screen.getByText("3 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("hides subtitle when all miners are reporting", () => { + const metrics = [createMockMetric(1500, 5)]; + + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("hides subtitle when device count is null", () => { + // No metrics, so device count will be null + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); + + it("shows subtitle with zero miners reporting", () => { + const metrics = [createMockMetric(0, 0)]; + + render(); + + expect(screen.getByText("0 of 5 miners reporting")).toBeInTheDocument(); + }); + + it("uses max device count across buckets, not the last bucket", () => { + // Arrange — first bucket has 5 devices, second (incomplete) bucket has only 3 + const metrics = [createMockMetric(1500, 5), createMockMetric(1400, 3)]; + + // Act + render(); + + // Assert — subtitle should reflect the max (5), not the last bucket (3) + expect(screen.getByText("5 of 7 miners reporting")).toBeInTheDocument(); + }); + + it("renders loading state without subtitle", () => { + // undefined = not loaded yet (loading state) + render(); + + expect(screen.queryByText(/miners reporting/)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.tsx b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.tsx new file mode 100644 index 000000000..cc2abbff3 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/PowerPanel.tsx @@ -0,0 +1,96 @@ +import { useMemo } from "react"; +import { transformPowerMetricsToChartData } from "./utils"; +import { type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import LineChart from "@/protoFleet/components/LineChart"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { padChartDataWithNulls } from "@/protoFleet/features/dashboard/utils/chartDataPadding"; +import { getMinerCountSubtitle } from "@/protoFleet/features/dashboard/utils/minerCountSubtitle"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface PowerPanelProps { + duration: FleetDuration; + /** Power metrics — undefined = not loaded yet, empty array = loaded but no data */ + metrics: Metric[] | undefined; + /** Total miner count for "X of Y miners reporting" subtitle */ + totalMiners: number; +} + +export function PowerPanel({ duration, metrics, totalMiners }: PowerPanelProps) { + // Transform metrics data to chart format (merging already done by store selectors) + const chartData = useMemo(() => { + if (metrics === undefined) return undefined; // Not loaded yet + if (metrics.length === 0) return null; // Loaded but no data + + const transformedData = transformPowerMetricsToChartData(metrics); + + // Pad with null values for the full duration + return padChartDataWithNulls(transformedData, duration); + }, [metrics, duration]); + + // Get the latest power value for the stat display + const currentPower = useMemo(() => { + if (chartData === undefined) return undefined; // Not loaded yet + if (chartData === null || chartData.length === 0) return null; // Loaded but no data + return chartData[chartData.length - 1]?.power ?? null; + }, [chartData]); + + // Use max device count across all buckets — the last bucket may be incomplete + // and fluctuate as new data arrives. + const deviceCount = useMemo(() => { + if (metrics === undefined) return undefined; + if (metrics.length === 0) return null; + return Math.max(...metrics.map((m) => m.deviceCount)); + }, [metrics]); + + // Show loading skeleton while data hasn't loaded yet + if (metrics === undefined) { + const stat = { + label: "Power", + value: undefined, + units: "", + }; + + return ( + + + + ); + } + + // Handle no data case - still show the widget with header but no chart + if (!chartData || chartData.length === 0) { + const stat = { + label: "Power", + value: "No data", + units: "", + }; + + return {null}; + } + + const powerDisplayValue = currentPower !== null && currentPower !== undefined ? currentPower.toFixed(1) : "N/A"; + + const subtitle = getMinerCountSubtitle(deviceCount ?? null, totalMiners); + const stat = { + label: "Power", + value: powerDisplayValue, + units: "kW", + subtitle, + tooltipContent: subtitle ? "Some devices do not make this data available to Proto Fleet." : undefined, + }; + + return ( + + + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/index.ts b/client/src/protoFleet/features/dashboard/components/PowerPanel/index.ts new file mode 100644 index 000000000..4e707dc31 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/index.ts @@ -0,0 +1 @@ +export { PowerPanel } from "./PowerPanel"; diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.test.ts new file mode 100644 index 000000000..1abb0721b --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { transformPowerMetricsToChartData } from "./utils"; +import { MeasurementType } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { createMockMetric } from "@/protoFleet/features/dashboard/utils/createMockMetric"; + +describe("transformPowerMetricsToChartData", () => { + it("returns empty array for empty metrics", () => { + expect(transformPowerMetricsToChartData([])).toEqual([]); + }); + + it("keeps already-normalized values unchanged", () => { + const metrics = [createMockMetric(MeasurementType.POWER, 3.2, 1000)]; + const result = transformPowerMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, power: 3.2 }]); + }); + + it("normalizes raw watt values to kW", () => { + const metrics = [createMockMetric(MeasurementType.POWER, 3200, 1000)]; + const result = transformPowerMetricsToChartData(metrics); + + expect(result).toEqual([{ datetime: 1000000, power: 3.2 }]); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.ts b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.ts new file mode 100644 index 000000000..de3032281 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/PowerPanel/utils.ts @@ -0,0 +1,28 @@ +import { AggregationType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { normalizePowerToKW } from "@/protoFleet/features/dashboard/utils/metricNormalization"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +/** + * Transform power metrics from the API to chart data format + * @param metrics - Array of Metric objects from GetCombinedMetricsResponse + * @returns Array of ChartData objects for LineChart + */ +export function transformPowerMetricsToChartData(metrics: Metric[]): ChartData[] { + if (!metrics || metrics.length === 0) { + return []; + } + + return metrics.map((metric) => { + // Find the AVERAGE aggregation value, default to the first value if not found + const avgValue = + metric.aggregatedValues.find((agg) => agg.aggregationType === AggregationType.AVERAGE)?.value ?? + metric.aggregatedValues[0]?.value ?? + 0; + const normalizedPower = normalizePowerToKW(avgValue, metric.deviceCount); + + return { + datetime: Number(metric.openTime?.seconds ?? 0) * 1000, // Convert seconds to milliseconds + power: normalizedPower, + }; + }); +} diff --git a/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.stories.tsx b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.stories.tsx new file mode 100644 index 000000000..071ed3843 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.stories.tsx @@ -0,0 +1,46 @@ +import SectionHeadingComponent from "."; +import Button, { sizes, variants } from "@/shared/components/Button"; +import DurationSelector from "@/shared/components/DurationSelector"; + +interface SectionHeadingArgs { + heading: string; + controlType: "none" | "durationSelector" | "button"; +} + +export const SectionHeading = ({ heading, controlType }: SectionHeadingArgs) => { + const renderControls = () => { + switch (controlType) { + case "durationSelector": + return ; + case "button": + return + + , + ); + + expect(screen.getByText("Performance")).toBeInTheDocument(); + expect(screen.getByText("1h")).toBeInTheDocument(); + expect(screen.getByText("24h")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render(); + + const sectionHeading = container.firstChild as HTMLElement; + expect(sectionHeading).toHaveClass("custom-class"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.tsx b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.tsx new file mode 100644 index 000000000..42012b201 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SectionHeading/SectionHeading.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from "react"; +import clsx from "clsx"; + +type SectionHeadingProps = { + heading: string; + children?: ReactNode; + className?: string; +}; + +const SectionHeading = ({ heading, children, className }: SectionHeadingProps) => { + return ( +
+
{heading}
+ {children ?
{children}
: null} +
+ ); +}; + +export default SectionHeading; diff --git a/client/src/protoFleet/features/dashboard/components/SectionHeading/index.ts b/client/src/protoFleet/features/dashboard/components/SectionHeading/index.ts new file mode 100644 index 000000000..d9b154156 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SectionHeading/index.ts @@ -0,0 +1 @@ +export { default } from "./SectionHeading"; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.stories.tsx b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.stories.tsx new file mode 100644 index 000000000..7beaddfee --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.stories.tsx @@ -0,0 +1,295 @@ +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import type { Meta, StoryObj } from "@storybook/react"; +import { SegmentedMetricPanel } from "./SegmentedMetricPanel"; +import type { SegmentConfig } from "./types"; +import type { TemperatureStatusCount } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { Triangle } from "@/shared/assets/icons"; +import { fleetDurations } from "@/shared/components/DurationSelector"; + +const meta = { + title: "Proto Fleet/Dashboard/SegmentedMetricPanel", + component: SegmentedMetricPanel, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "A panel component that combines ChartWidget with a SegmentedBarChart and current status breakdown. Supports granular intervals for short durations and multi-chart display for longer periods.", + }, + }, + }, + argTypes: { + title: { + control: "text", + description: "The title displayed at the top of the panel", + }, + headline: { + control: "text", + description: "Summary headline shown below the title", + }, + chartData: { + control: "object", + description: "Temperature status count data from the API", + }, + segmentConfig: { + control: "object", + description: "Configuration for each segment (color, label, etc.)", + }, + duration: { + control: "select", + options: fleetDurations, + description: "Time duration for the chart display", + }, + }, + decorators: [ + (Story) => ( +
+
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper to create timestamp from date +const createTimestamp = (date: Date): Timestamp => { + const millis = date.getTime(); + const seconds = Math.floor(millis / 1000); + const nanos = (millis % 1000) * 1000000; + return { + seconds: seconds as any, // Protobuf expects bigint but for display we use number + nanos, + } as Timestamp; +}; + +// Generate highly granular mock data (every minute) +const generateGranularData = ( + hours: number, + basePattern?: { + cold?: number; + ok?: number; + hot?: number; + critical?: number; + }, +): TemperatureStatusCount[] => { + const data: TemperatureStatusCount[] = []; + const now = new Date(); + const minutesTotal = hours * 60; + + // Default pattern if not provided + const pattern = { + cold: 2, + ok: 180, + hot: 15, + critical: 3, + ...basePattern, // Override with provided values + }; + + for (let i = 0; i < minutesTotal; i++) { + const time = new Date(now.getTime() - (minutesTotal - i - 1) * 60 * 1000); + + // Add some variation to make it realistic + const variation = Math.sin(i / 10) * 0.1; // ±10% variation + + data.push({ + timestamp: createTimestamp(time), + coldCount: Math.max(0, Math.floor(pattern.cold + pattern.cold * variation)), + okCount: Math.max(0, Math.floor(pattern.ok + pattern.ok * variation)), + hotCount: Math.max(0, Math.floor(pattern.hot + pattern.hot * variation)), + criticalCount: Math.max(0, Math.floor(pattern.critical + pattern.critical * variation)), + } as TemperatureStatusCount); + } + + return data; +}; + +// Temperature segment configuration +const temperatureSegmentConfig: SegmentConfig = { + cold: { + color: "var(--color-intent-info-fill)", + label: "Cold", + displayInBreakdown: true, + index: 2, // Third in order + }, + ok: { + color: "var(--color-intent-info-20)", + label: "Healthy", + displayInBreakdown: true, + index: 3, // Fourth in order + showButton: false, + percentageLabel: "Within optimal range", // Custom label for normal temperature + }, + hot: { + color: "var(--color-intent-warning-fill)", + label: "Hot", + displayInBreakdown: true, + index: 1, // Second in order + }, + critical: { + color: "var(--color-intent-critical-fill)", + label: "Critical", + displayInBreakdown: true, + icon: , + index: 0, // First in order + buttonVariant: "primary", // Use primary button for critical items + }, +}; + +// 1 Hour Duration - Shows 5-minute intervals +export const OneHourDuration: Story = { + args: { + title: "Temperature", + headline: "8.5% outside safe range", + chartData: generateGranularData(1.5), // Generate 1.5 hours of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "1h", + }, +}; + +// 12 Hour Duration - Shows hourly intervals +export const TwelveHourDuration: Story = { + args: { + title: "Temperature", + headline: "9.0% outside safe range", + chartData: generateGranularData(13), // Generate 13 hours of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// 24 Hour Duration - Shows 2-hour intervals +export const TwentyFourHourDuration: Story = { + args: { + title: "Temperature", + headline: "10.0% outside safe range", + chartData: generateGranularData(25), // Generate 25 hours of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// 7 Day Duration - Multiple charts with 2 bars per day +export const SevenDayDuration: Story = { + args: { + title: "Temperature", + headline: "9.5% outside safe range", + chartData: generateGranularData(169), // Generate just over 7 days of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "7d", + }, +}; + +// 30 Day Duration - Daily bars over a month +export const ThirtyDayDuration: Story = { + args: { + title: "Temperature", + headline: "10.5% outside safe range", + chartData: generateGranularData(24 * 31), // Generate just over 30 days of minute-level data + segmentConfig: temperatureSegmentConfig, + duration: "30d", + }, +}; + +// With percentage display enabled +export const WithPercentages: Story = { + args: { + title: "Temperature", + headline: "11.0% outside safe range", + chartData: generateGranularData(24, { + cold: 5, + ok: 170, + hot: 20, + critical: 5, + }), + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Edge case: Very few miners +export const FewMiners: Story = { + args: { + title: "Temperature", + headline: "25.0% outside safe range", + chartData: generateGranularData(12, { + cold: 1, + ok: 6, + hot: 2, + critical: 1, + }), + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Edge case: All miners in one category +export const AllNormal: Story = { + args: { + title: "Temperature", + headline: "0.0% outside safe range", + chartData: generateGranularData(6, { + cold: 0, + ok: 200, + hot: 0, + critical: 0, + }), + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Edge case: No data +export const NoData: Story = { + args: { + title: "Temperature", + headline: "No data", + chartData: [], + segmentConfig: temperatureSegmentConfig, + duration: "24h", + }, +}; + +// Custom segment configuration (different use case) +const uptimeSegmentConfig: SegmentConfig = { + offline: { + color: "var(--color-intent-critical-fill)", + label: "Offline", + displayInBreakdown: true, + }, + sleeping: { + color: "var(--color-intent-warning-fill)", + label: "Sleeping", + displayInBreakdown: true, + }, + broken: { + color: "var(--color-intent-info-fill)", + label: "Broken", + displayInBreakdown: true, + }, + hashing: { + color: "var(--color-intent-success-fill)", + label: "Hashing", + displayInBreakdown: true, + }, +}; + +// Alternative use case: Uptime monitoring +export const UptimeMonitoring: Story = { + args: { + title: "Uptime", + headline: "5.0% not hashing", + chartData: generateGranularData(24, { + cold: 5, // Using cold for offline + ok: 190, // Using ok for hashing + hot: 3, // Using hot for sleeping + critical: 2, // Using critical for broken + }), + segmentConfig: uptimeSegmentConfig, + duration: "24h", + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.tsx b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.tsx new file mode 100644 index 000000000..bd4f03935 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/SegmentedMetricPanel.tsx @@ -0,0 +1,176 @@ +import { useMemo } from "react"; + +import { DEFAULT_CHART_HEIGHT } from "./constants"; +import type { SegmentedMetricPanelProps } from "./types"; +import { durationToHours, getCurrentBreakdown, processMultiDayChartData } from "./utils"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { StatusBreakdownPanel } from "@/protoFleet/features/dashboard/components/StatusBreakdownPanel"; +import SegmentedBarChart from "@/shared/components/SegmentedBarChart"; + +// Constants for bar chart display +const MULTI_DAY_BAR_WIDTH = { + desktop: 8, + laptop: 6, + tablet: 8, + phone: 6, +}; + +// Duration thresholds (in hours) +const ONE_DAY_IN_HOURS = 24; +const TWO_DAYS_IN_HOURS = 48; +const TEN_DAYS_IN_HOURS = 240; + +// X-axis tick intervals based on duration +const TICK_INTERVAL_SINGLE_DAY = 1; +const TICK_INTERVAL_SHORT_MULTI_DAY = 3; +const TICK_INTERVAL_MEDIUM_MULTI_DAY = 2; +const TICK_INTERVAL_LONG_MULTI_DAY = 4; + +/** + * Determines the x-axis tick interval based on duration. + * Shorter durations show more ticks, longer durations show fewer to prevent overlap. + */ +const getXAxisTickInterval = (hours: number, isMultiDay: boolean): number => { + if (!isMultiDay) { + return TICK_INTERVAL_SINGLE_DAY; + } + if (hours <= TWO_DAYS_IN_HOURS) { + return TICK_INTERVAL_SHORT_MULTI_DAY; + } + if (hours <= TEN_DAYS_IN_HOURS) { + return TICK_INTERVAL_MEDIUM_MULTI_DAY; + } + return TICK_INTERVAL_LONG_MULTI_DAY; +}; + +/** + * Determines whether to use date format (e.g., "1/15") for x-axis ticks. + * Use date format for multi-day durations where bars represent multiple hours. + */ +const shouldUseDateFormat = (hours: number): boolean => { + return hours > ONE_DAY_IN_HOURS; +}; + +export const SegmentedMetricPanel = ({ + title, + headline, + headlineGenerator, + chartData, + segmentConfig, + duration, + className, +}: SegmentedMetricPanelProps) => { + // Process the chart data - returns array of arrays for multi-day views + const processedChartData = useMemo( + () => processMultiDayChartData(chartData, duration, segmentConfig), + [chartData, duration, segmentConfig], + ); + + // Calculate current breakdown from processed chart data (shares logic with chart) + const currentBreakdown = useMemo( + () => getCurrentBreakdown(processedChartData, segmentConfig), + [processedChartData, segmentConfig], + ); + + // Extract segment keys from config + const segmentKeys = useMemo(() => Object.keys(segmentConfig), [segmentConfig]); + + // Build color map from config + const colorMap = useMemo( + () => + Object.entries(segmentConfig).reduce( + (acc, [key, config]) => { + acc[key] = config.color; + return acc; + }, + {} as Record, + ), + [segmentConfig], + ); + + // Determine if we're showing multiple charts + const hours = durationToHours(duration); + const isMultiDay = hours > 24; + + // Calculate bar width for multi-chart layout + const barWidth = useMemo(() => { + if (!isMultiDay) return undefined; + return MULTI_DAY_BAR_WIDTH; + }, [isMultiDay]); + + // Calculate equal chart widths for multi-day view + const chartWidths = useMemo(() => { + if (!isMultiDay) return ["100%"]; + + const numberOfCharts = processedChartData.length; + const chartWidthPercentage = `${100 / numberOfCharts}%`; + return processedChartData.map(() => chartWidthPercentage); + }, [isMultiDay, processedChartData]); + + // Generate headline using the generator function if provided, otherwise use static headline + const computedHeadline = useMemo(() => { + if (headlineGenerator && processedChartData.length > 0) { + return headlineGenerator(processedChartData); + } + return headline || ""; + }, [headlineGenerator, processedChartData, headline]); + + // Check if we have no data + const hasNoData = !chartData || chartData.length === 0; + + const stat = { + label: title, + value: hasNoData ? "No data" : computedHeadline, + }; + + // If no data, render just the ChartWidget without charts or breakdown + if (hasNoData) { + return {null}; + } + + return ( +
+ {/* Left Panel: ChartWidget with SegmentedBarChart(s) */} + +
+ {processedChartData.map((dayData, index) => { + // Use pre-calculated width for this chart + const chartWidth = chartWidths[index]; + + return ( +
+ 1} + useDateFormat={shouldUseDateFormat(hours)} + lastTickOverride={!isMultiDay && hours < 24 ? "live" : undefined} + /> +
+ ); + })} +
+
+ + {/* Right Panel: Current Values Breakdown */} + +
+ ); +}; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/constants.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/constants.ts new file mode 100644 index 000000000..d2461fae9 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/constants.ts @@ -0,0 +1,14 @@ +// Default color mappings for common status types +export const STATUS_COLORS = { + // Uptime statuses + hashing: "--color-intent-success-fill", + notHashing: "--color-text-primary-20", + + // Temperature statuses + normal: "--color-intent-success-fill", + hot: "--color-intent-warning-fill", + critical: "--color-intent-critical-fill", + cold: "--color-intent-info-fill", +} as const; + +export const DEFAULT_CHART_HEIGHT = 284; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/index.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/index.ts new file mode 100644 index 000000000..722c82ee1 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/index.ts @@ -0,0 +1,3 @@ +export { SegmentedMetricPanel } from "./SegmentedMetricPanel"; +export type { SegmentedMetricPanelProps, SegmentConfig } from "./types"; +export { STATUS_COLORS } from "./constants"; diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/types.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/types.ts new file mode 100644 index 000000000..2665748f7 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/types.ts @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import type { ButtonVariant } from "@/shared/components/Button"; +import type { FleetDuration } from "@/shared/components/DurationSelector"; + +export interface SegmentedBarChartData { + datetime: number; + [key: string]: number; +} + +export interface SegmentConfig { + [key: string]: { + color: string; + label: string; + icon?: ReactNode; + displayInBreakdown?: boolean; + index?: number; // Controls the order segments appear in the breakdown + buttonVariant?: ButtonVariant; // Button variant for the segment + percentageLabel?: string; // Custom label to show instead of "n% of fleet" + showButton?: boolean; // Whether to show the button with miner count (defaults to true) + onClick?: () => void; // Optional click handler for the segment button + }; +} + +// Generic status count with timestamp +// Supports Protobuf-generated types with $typeName and $unknown properties +export interface StatusCount { + timestamp?: Timestamp; + [key: string]: number | Timestamp | string | unknown[] | undefined; +} + +export interface SegmentedMetricPanelProps { + title: string; + headline?: string; // Optional static headline + headlineGenerator?: (processedData: SegmentedBarChartData[][]) => string; // Optional dynamic headline generator + chartData: StatusCount[]; + segmentConfig: SegmentConfig; + duration: FleetDuration; + className?: string; +} diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.test.ts new file mode 100644 index 000000000..505886477 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.test.ts @@ -0,0 +1,657 @@ +import { describe, expect, it } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; +import type { SegmentConfig, SegmentedBarChartData, StatusCount } from "./types"; +import { getCurrentBreakdown, processChartData, processMultiDayChartData } from "./utils"; + +describe("getCurrentBreakdown", () => { + const mockSegmentConfig: SegmentConfig = { + hashing: { + color: "var(--color-text-primary)", + label: "Hashing", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + notHashing: { + color: "var(--color-core-primary-10)", + label: "Not hashing", + displayInBreakdown: true, + showButton: true, + buttonVariant: "secondary", + index: 0, + }, + }; + + it("returns empty array when processedChartData is empty", () => { + const result = getCurrentBreakdown([], mockSegmentConfig); + expect(result).toEqual([]); + }); + + it("returns empty array when processedChartData has empty charts", () => { + const result = getCurrentBreakdown([[]], mockSegmentConfig); + expect(result).toEqual([]); + }); + + it("calculates breakdown from last bar of single-day chart", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 10000, + hashing: 5, + notHashing: 0, + }, + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + key: "notHashing", + label: "Not hashing", + count: 2, + percentage: 40, + }); + expect(result[1]).toMatchObject({ + key: "hashing", + label: "Hashing", + count: 3, + percentage: 60, + }); + }); + + it("calculates breakdown from last bar of last day in multi-day chart", () => { + const processedData: SegmentedBarChartData[][] = [ + // Day 1 + [ + { + datetime: Date.now() - 20000, + hashing: 5, + notHashing: 0, + }, + ], + // Day 2 + [ + { + datetime: Date.now() - 10000, + hashing: 4, + notHashing: 1, + }, + ], + // Day 3 (most recent) + [ + { + datetime: Date.now(), + hashing: 2, + notHashing: 3, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + key: "notHashing", + count: 3, + percentage: 60, + }); + expect(result[1]).toMatchObject({ + key: "hashing", + count: 2, + percentage: 40, + }); + }); + + it("handles zero total count", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 0, + notHashing: 0, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + expect(result).toHaveLength(2); + expect(result[0].percentage).toBe(0); + expect(result[1].percentage).toBe(0); + }); + + it("rounds percentages correctly", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 6, + notHashing: 1, // 1/7 = 14.28% + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + expect(notHashingSegment?.percentage).toBe(14); + }); + + it("handles undefined segment values", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 5, + // notHashing is undefined + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + expect(notHashingSegment?.count).toBe(0); + }); + + it("uses custom percentage label when provided", () => { + const customConfig: SegmentConfig = { + ...mockSegmentConfig, + notHashing: { + ...mockSegmentConfig.notHashing, + percentageLabel: "Custom label", + }, + }; + + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, customConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + expect(notHashingSegment?.percentageLabel).toBe("Custom label"); + }); + + it("sorts breakdown by index", () => { + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + // notHashing has index 0, hashing has index 1 + expect(result[0].key).toBe("notHashing"); + expect(result[1].key).toBe("hashing"); + }); + + it("filters out segments with displayInBreakdown = false", () => { + const configWithHidden: SegmentConfig = { + ...mockSegmentConfig, + hashing: { + ...mockSegmentConfig.hashing, + displayInBreakdown: false, + }, + }; + + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, configWithHidden); + + expect(result).toHaveLength(1); + expect(result[0].key).toBe("notHashing"); + }); + + describe("Edge case: Legend uses processed chart data, ensuring consistency", () => { + it("uses the exact data from the last processed chart bar", () => { + // This test verifies the fix for zero-value edge case: + // Legend should use the same data as the chart's last bar, + // not independently process raw data which could have newer timestamps + + // Create processed chart data directly (as if from processMultiDayChartData) + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 10000, + hashing: 5, + notHashing: 0, + }, + { + datetime: Date.now() - 5000, + hashing: 3, + notHashing: 2, // This is what the chart's last bar shows + }, + ], + ]; + + // Get breakdown - should use exact values from last bar + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + const hashingSegment = result.find((s) => s.key === "hashing"); + + // Verify it matches the last bar exactly + expect(notHashingSegment?.count).toBe(2); + expect(hashingSegment?.count).toBe(3); + }); + + it("always matches chart's last bar in multi-day view", () => { + // Multi-day scenario: Legend should use the last bar of the last day + const processedData: SegmentedBarChartData[][] = [ + // Day 1 + [ + { + datetime: Date.now() - 48 * 60 * 60 * 1000, + hashing: 10, + notHashing: 0, + }, + ], + // Day 2 (most recent day) + [ + { + datetime: Date.now() - 12 * 60 * 60 * 1000, + hashing: 7, + notHashing: 1, + }, + { + datetime: Date.now(), // Last bar of last day + hashing: 4, + notHashing: 3, + }, + ], + ]; + + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + const notHashingSegment = result.find((s) => s.key === "notHashing"); + const hashingSegment = result.find((s) => s.key === "hashing"); + + // Should match the last bar (4 hashing, 3 not hashing) + // Not day 1 data (10, 0) or first bar of day 2 (7, 1) + expect(hashingSegment?.count).toBe(4); + expect(notHashingSegment?.count).toBe(3); + }); + + it("breakdown and chart are guaranteed to be in sync", () => { + // The key guarantee: since getCurrentBreakdown takes processed chart data, + // it's IMPOSSIBLE for them to be out of sync + const processedData: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 100, + notHashing: 50, + }, + ], + ]; + + // Get the last bar that the chart displays + const lastChart = processedData[processedData.length - 1]; + const lastBar = lastChart[lastChart.length - 1]; + + // Get the breakdown + const result = getCurrentBreakdown(processedData, mockSegmentConfig); + + // They MUST match because breakdown uses the same processed data + const notHashingSegment = result.find((s) => s.key === "notHashing"); + const hashingSegment = result.find((s) => s.key === "hashing"); + + expect(hashingSegment?.count).toBe(lastBar.hashing); + expect(notHashingSegment?.count).toBe(lastBar.notHashing); + }); + }); +}); + +describe("processChartData - Last interval uses latest data", () => { + const segmentConfig: SegmentConfig = { + cold: { + color: "var(--color-core-blue-60)", + label: "Cold", + displayInBreakdown: true, + showButton: false, + index: 0, + }, + ok: { + color: "var(--color-core-green-60)", + label: "OK", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + hot: { + color: "var(--color-core-orange-60)", + label: "Hot", + displayInBreakdown: true, + showButton: false, + index: 2, + }, + critical: { + color: "var(--color-core-red-60)", + label: "Critical", + displayInBreakdown: true, + showButton: false, + index: 3, + }, + }; + + it("should use most recent data point for last interval even if after boundary", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 10 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 5, + okCount: 10, + hotCount: 2, + criticalCount: 0, + }, // 10 min ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processChartData(data, "12h", segmentConfig); + const lastBar = result[result.length - 1]; + + // Last bar should use latest data (coldCount: 8), not interval-bounded data + expect(lastBar.cold).toBe(8); + expect(lastBar.ok).toBe(12); + expect(lastBar.hot).toBe(3); + expect(lastBar.critical).toBe(1); + }); + + it("should use interval-bounded data for non-last intervals", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 10 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 5, + okCount: 10, + hotCount: 2, + criticalCount: 0, + }, // 10 min ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processChartData(data, "12h", segmentConfig); + + // First bars should use interval-bounded data, not latest + // Verify that at least one non-last bar exists and doesn't use latest data + expect(result.length).toBeGreaterThan(1); + + // The first bar should use the first data point (or null if no data before that interval) + const firstBar = result[0]; + // First bar might be 0 if no data before that interval + // But it definitely shouldn't have the latest values (8, 12, 3, 1) + const isUsingLatestData = + firstBar.cold === 8 && firstBar.ok === 12 && firstBar.hot === 3 && firstBar.critical === 1; + expect(isUsingLatestData).toBe(false); + }); + + it("should handle empty data gracefully", () => { + const data: StatusCount[] = []; + + const result = processChartData(data, "12h", segmentConfig); + + // Should return 12 intervals with all zeros + expect(result.length).toBe(12); + result.forEach((bar) => { + expect(bar.cold).toBe(0); + expect(bar.ok).toBe(0); + expect(bar.hot).toBe(0); + expect(bar.critical).toBe(0); + }); + }); + + it("should handle single data point correctly", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 5 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 3, + okCount: 7, + hotCount: 1, + criticalCount: 0, + }, + ]; + + const result = processChartData(data, "12h", segmentConfig); + const lastBar = result[result.length - 1]; + + // Last bar should use the only data point + expect(lastBar.cold).toBe(3); + expect(lastBar.ok).toBe(7); + expect(lastBar.hot).toBe(1); + expect(lastBar.critical).toBe(0); + }); +}); + +describe("processMultiDayChartData - Last interval uses latest data", () => { + const segmentConfig: SegmentConfig = { + cold: { + color: "var(--color-core-blue-60)", + label: "Cold", + displayInBreakdown: true, + showButton: false, + index: 0, + }, + ok: { + color: "var(--color-core-green-60)", + label: "OK", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + hot: { + color: "var(--color-core-orange-60)", + label: "Hot", + displayInBreakdown: true, + showButton: false, + index: 2, + }, + critical: { + color: "var(--color-core-red-60)", + label: "Critical", + displayInBreakdown: true, + showButton: false, + index: 3, + }, + }; + + it("should use most recent data point for last interval of last day", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 48 * 60 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 3, + okCount: 8, + hotCount: 1, + criticalCount: 0, + }, // 48 hours ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // Get the last chart (last day) + const lastDay = result[result.length - 1]; + const lastBar = lastDay[lastDay.length - 1]; + + // Last bar of last day should use latest data + expect(lastBar.cold).toBe(8); + expect(lastBar.ok).toBe(12); + expect(lastBar.hot).toBe(3); + expect(lastBar.critical).toBe(1); + }); + + it("should use interval-bounded data for non-last intervals", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 48 * 60 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 2, + okCount: 5, + hotCount: 0, + criticalCount: 0, + }, // 48 hours ago + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 8, + okCount: 12, + hotCount: 3, + criticalCount: 1, + }, // 2 min ago (latest) + ]; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // First day's bars should not all use the latest data + const firstDay = result[0]; + const firstBar = firstDay[0]; + + // First bar should not have latest values + const isUsingLatestData = + firstBar.cold === 8 && firstBar.ok === 12 && firstBar.hot === 3 && firstBar.critical === 1; + expect(isUsingLatestData).toBe(false); + }); + + it("should handle empty data gracefully", () => { + const data: StatusCount[] = []; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // Should return structured data with zeros + expect(result.length).toBeGreaterThan(0); + result.forEach((day) => { + day.forEach((bar) => { + expect(bar.cold).toBe(0); + expect(bar.ok).toBe(0); + expect(bar.hot).toBe(0); + expect(bar.critical).toBe(0); + }); + }); + }); + + it("should delegate to processChartData for durations <= 24h", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 2 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 5, + okCount: 10, + hotCount: 2, + criticalCount: 1, + }, + ]; + + const result = processMultiDayChartData(data, "12h", segmentConfig); + + // Should return single-day array + expect(result.length).toBe(1); + const singleDay = result[0]; + const lastBar = singleDay[singleDay.length - 1]; + + // Last bar should use latest data (same as processChartData) + expect(lastBar.cold).toBe(5); + expect(lastBar.ok).toBe(10); + }); + + it("should handle single data point correctly across multiple days", () => { + const now = Date.now(); + const data: StatusCount[] = [ + { + timestamp: create(TimestampSchema, { + seconds: BigInt(Math.floor((now - 5 * 60 * 1000) / 1000)), + nanos: 0, + }), + coldCount: 3, + okCount: 7, + hotCount: 1, + criticalCount: 0, + }, + ]; + + const result = processMultiDayChartData(data, "48h", segmentConfig); + + // Last bar of last day should use the only data point + const lastDay = result[result.length - 1]; + const lastBar = lastDay[lastDay.length - 1]; + + expect(lastBar.cold).toBe(3); + expect(lastBar.ok).toBe(7); + expect(lastBar.hot).toBe(1); + expect(lastBar.critical).toBe(0); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.ts b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.ts new file mode 100644 index 000000000..f3099de23 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils.ts @@ -0,0 +1,383 @@ +import { timestampMs } from "@bufbuild/protobuf/wkt"; +import type { SegmentConfig, SegmentedBarChartData, StatusCount } from "./types"; + +/** + * Convert segment key to field name (e.g., "cold" -> "coldCount", "notHashing" -> "notHashingCount") + */ +const segmentKeyToFieldName = (key: string): string => { + return `${key}Count`; +}; + +/** + * Get count value from data point for a given segment key + */ +const getCountForSegment = (dataPoint: StatusCount | null, segmentKey: string): number => { + if (!dataPoint) return 0; + const fieldName = segmentKeyToFieldName(segmentKey); + const value = dataPoint[fieldName]; + return typeof value === "number" ? value : 0; +}; + +/** + * Convert duration string to hours + */ +export const durationToHours = (duration: string): number => { + const value = parseInt(duration.slice(0, -1)); + const unit = duration.slice(-1); + + switch (unit) { + case "h": + return value; + case "d": + return value * 24; + case "y": + return value * 365 * 24; + default: + return 12; // Default to 12 hours + } +}; + +/** + * Generate timestamps for chart intervals with appropriate granularity + */ +export const getHourlyIntervals = (duration: string): number[] => { + const hours = durationToHours(duration); + const now = new Date(); + const intervals: number[] = []; + + // Always try to show 12 intervals + const intervalCount = 12; + + // Calculate interval in minutes + const totalMinutes = hours * 60; + let minutesPerInterval = totalMinutes / intervalCount; + + // Round to clean boundaries for better readability + if (minutesPerInterval <= 5) { + minutesPerInterval = 5; + } else if (minutesPerInterval <= 10) { + minutesPerInterval = 10; + } else if (minutesPerInterval <= 15) { + minutesPerInterval = 15; + } else if (minutesPerInterval <= 30) { + minutesPerInterval = 30; + } else if (minutesPerInterval <= 60) { + minutesPerInterval = 60; + } else if (minutesPerInterval <= 120) { + minutesPerInterval = 120; + } else if (minutesPerInterval <= 240) { + minutesPerInterval = 240; + } else if (minutesPerInterval <= 600) { + minutesPerInterval = 600; + } else { + // For very long durations, round to nearest hour + minutesPerInterval = Math.ceil(minutesPerInterval / 60) * 60; + } + + // Round current time UP to the next interval boundary + const endTime = new Date(now); + endTime.setSeconds(0, 0); + const currentMinutes = endTime.getMinutes(); + const roundedMinutes = Math.ceil(currentMinutes / minutesPerInterval) * minutesPerInterval; + + // If we rounded up to 60 minutes, move to the next hour + if (roundedMinutes === 60) { + endTime.setHours(endTime.getHours() + 1); + endTime.setMinutes(0); + } else { + endTime.setMinutes(roundedMinutes); + } + + // Calculate the start time (going back from the rounded end time) + const startTime = endTime.getTime() - totalMinutes * 60 * 1000; + + // Generate intervals from start to end + for (let i = 0; i < intervalCount; i++) { + const intervalTime = startTime + i * minutesPerInterval * 60 * 1000; + intervals.push(intervalTime); + } + + return intervals; +}; + +/** + * Find the data point immediately before or at a given timestamp + */ +export const findDataPointBefore = (data: StatusCount[], timestamp: number): StatusCount | null => { + if (!data || data.length === 0) return null; + + // Find the last data point that is before or at the timestamp + let bestPoint: StatusCount | null = null; + + for (const point of data) { + const pointTime = point.timestamp ? timestampMs(point.timestamp) : 0; + + if (pointTime <= timestamp) { + bestPoint = point; + } else { + break; // Data is sorted, so we can stop once we pass the timestamp + } + } + + return bestPoint; +}; + +/** + * Process raw status counts into chart data + */ +export const processChartData = ( + data: StatusCount[], + duration: string, + segmentConfig: SegmentConfig, +): SegmentedBarChartData[] => { + const hourlyIntervals = getHourlyIntervals(duration); + const processedData: SegmentedBarChartData[] = []; + const segmentKeys = Object.keys(segmentConfig); + + // If no data, return empty data points for all intervals + if (!data || data.length === 0) { + return hourlyIntervals.map((interval) => { + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = 0; + }); + return chartPoint; + }); + } + + // Sort data by timestamp + const sortedData = [...data].sort((a, b) => { + const timeA = a.timestamp ? timestampMs(a.timestamp) : 0; + const timeB = b.timestamp ? timestampMs(b.timestamp) : 0; + return timeA - timeB; + }); + + // For each hourly interval, find the appropriate data point + for (let i = 0; i < hourlyIntervals.length; i++) { + const interval = hourlyIntervals[i]; + const isLastInterval = i === hourlyIntervals.length - 1; + + // For last interval, use absolute latest data; for others, use data at interval + const dataPoint = isLastInterval + ? sortedData[sortedData.length - 1] // Latest data + : findDataPointBefore(sortedData, interval); // Data at interval boundary + + // Always create a chart point for every interval + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = getCountForSegment(dataPoint, key); + }); + processedData.push(chartPoint); + } + + return processedData; +}; + +/** + * Bucketing configuration based on duration for preventing bar overlap + */ +interface BucketConfig { + hoursPerBucket: number; +} + +/** + * Determine bucket size based on duration to prevent bar overlap. + * Target bar counts: + * - 7d: 14 bars (2 bars/day = 12h buckets) + * - 30d: 30 bars (1 bar/day = 24h buckets) + * - 90d: ~13 bars (weekly = 168h buckets) + * - 1y: ~26 bars (bi-weekly = 336h buckets) + */ +const getBucketConfig = (days: number): BucketConfig => { + if (days <= 3) { + // Up to 3d: 6 bars per day (4h buckets) + return { hoursPerBucket: 4 }; + } else if (days <= 10) { + // Up to 10d: 2 bars per day (12h buckets) + return { hoursPerBucket: 12 }; + } else if (days <= 30) { + // 30d: 1 bar per day (24h buckets) + return { hoursPerBucket: 24 }; + } else if (days <= 90) { + // 90d: Weekly buckets (168h = 7 days) + return { hoursPerBucket: 24 * 7 }; + } else { + // 1y+: Bi-weekly buckets (336h = 14 days) + return { hoursPerBucket: 24 * 14 }; + } +}; + +/** + * Generate intervals for multi-day charts with adaptive bucketing. + * Uses different bucket sizes based on duration to prevent bar overlap. + * Returns a flat array wrapped in an array for compatibility with existing code. + */ +export const getMultiDayIntervals = (duration: string): number[][] => { + const hours = durationToHours(duration); + const now = new Date(); + const currentTime = Date.now(); + + // For durations <= 24h, use single chart (handled by getHourlyIntervals) + if (hours <= 24) { + return [getHourlyIntervals(duration)]; + } + + const days = hours / 24; + const { hoursPerBucket } = getBucketConfig(days); + const minutesPerBar = hoursPerBucket * 60; + + // Calculate start and end times + const endTime = new Date(now); + endTime.setMinutes(0, 0, 0); + const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000); + + // Generate all intervals as a single flat list + const intervals: number[] = []; + let currentInterval = new Date(startTime); + currentInterval.setHours(0, 0, 0, 0); // Start at beginning of first bucket + + while (currentInterval <= endTime) { + // Only include intervals that are: + // 1. After or at the start time + // 2. Before or at the end time + // 3. Not in the future + if (currentInterval >= startTime && currentInterval <= endTime && currentInterval.getTime() <= currentTime) { + intervals.push(currentInterval.getTime()); + } + + // Move to next interval + currentInterval = new Date(currentInterval.getTime() + minutesPerBar * 60 * 1000); + } + + // Return as single chart (all bars in one row) + return [intervals]; +}; + +/** + * Process data for multi-day charts + */ +export const processMultiDayChartData = ( + data: StatusCount[], + duration: string, + segmentConfig: SegmentConfig, +): SegmentedBarChartData[][] => { + const hours = durationToHours(duration); + + // For durations <= 24h, use single chart + if (hours <= 24) { + return [processChartData(data, duration, segmentConfig)]; + } + + const dayIntervals = getMultiDayIntervals(duration); + const processedDays: SegmentedBarChartData[][] = []; + const segmentKeys = Object.keys(segmentConfig); + + // If no data, return empty data points for all intervals + if (!data || data.length === 0) { + return dayIntervals.map((intervals) => { + return intervals.map((interval) => { + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = 0; + }); + return chartPoint; + }); + }); + } + + // Sort data by timestamp + const sortedData = data + ? [...data].sort((a, b) => { + const timeA = a.timestamp ? timestampMs(a.timestamp) : 0; + const timeB = b.timestamp ? timestampMs(b.timestamp) : 0; + return timeA - timeB; + }) + : []; + + // Process each day's intervals + for (let dayIndex = 0; dayIndex < dayIntervals.length; dayIndex++) { + const intervals = dayIntervals[dayIndex]; + const dayData: SegmentedBarChartData[] = []; + + for (let i = 0; i < intervals.length; i++) { + const interval = intervals[i]; + const isLastIntervalOfLastDay = + dayIndex === dayIntervals.length - 1 && // Last day + i === intervals.length - 1; // Last interval of that day + + // For last interval of last day, use absolute latest data + const dataPoint = isLastIntervalOfLastDay + ? sortedData[sortedData.length - 1] + : findDataPointBefore(sortedData, interval); + + const chartPoint: SegmentedBarChartData = { datetime: interval }; + segmentKeys.forEach((key) => { + chartPoint[key] = getCountForSegment(dataPoint, key); + }); + dayData.push(chartPoint); + } + + processedDays.push(dayData); + } + + return processedDays; +}; + +/** + * Calculate current breakdown from processed chart data + */ +export const getCurrentBreakdown = (processedChartData: SegmentedBarChartData[][], segmentConfig: SegmentConfig) => { + // Get the last chart (for multi-day view, this is the most recent day) + if (!processedChartData || processedChartData.length === 0) return []; + const lastChart = processedChartData[processedChartData.length - 1]; + + // Get the last data point from the last chart (most recent bar) + if (!lastChart || lastChart.length === 0) return []; + const latestDataPoint = lastChart[lastChart.length - 1]; + + const segmentKeys = Object.keys(segmentConfig); + + // Calculate total from all segment counts + const total = segmentKeys.reduce((sum, key) => sum + ((latestDataPoint[key] as number) || 0), 0); + + const breakdown = []; + + for (const [key, config] of Object.entries(segmentConfig)) { + const count = (latestDataPoint[key] as number) || 0; + + // Include all segments that should be displayed in breakdown, regardless of count + if (config.displayInBreakdown !== false) { + const percentageValue = total > 0 ? Math.round((count / total) * 100) : 0; + const percentageLabel = config.percentageLabel || `${percentageValue}% of fleet`; + + breakdown.push({ + key, + label: config.label, + count, + percentage: percentageValue, + percentageLabel, + color: config.color.replace("var(", "").replace(")", ""), // Remove var() wrapper for inline style + icon: config.icon, + index: config.index ?? 999, // Default to 999 if no index specified + buttonVariant: config.buttonVariant ?? "secondary", // Default to secondary if not specified + showButton: config.showButton !== false, // Default to true if not specified + onClick: config.onClick, // Pass through the onClick handler + }); + } + } + + // Sort by index (lower index appears first) + breakdown.sort((a, b) => a.index - b.index); + + return breakdown; +}; + +/** + * Format miner count with proper singular/plural + */ +export const formatMinerCount = (count: number): string => { + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}k`; + } + return count.toString(); +}; diff --git a/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/StatusBreakdownPanel.tsx b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/StatusBreakdownPanel.tsx new file mode 100644 index 000000000..238bf7e3b --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/StatusBreakdownPanel.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import clsx from "clsx"; + +import type { ButtonVariant } from "@/shared/components/Button"; +import Button, { variants } from "@/shared/components/Button"; +import Divider from "@/shared/components/Divider"; + +export interface StatusBreakdownItem { + key: string; + color: string; + label: string; + icon?: ReactNode; + percentageLabel: string; + count: number; + showButton?: boolean; + buttonVariant?: ButtonVariant; + onClick?: () => void; +} + +interface StatusBreakdownPanelProps { + items: StatusBreakdownItem[]; + className?: string; +} + +export const StatusBreakdownPanel = ({ items, className }: StatusBreakdownPanelProps) => { + return ( +
+ {items.map((segment, idx) => ( +
+ {segment.icon ? ( + + {segment.icon} + + ) : ( +
+ )} + +
+ {segment.label} + {segment.percentageLabel} +
+ + {segment.showButton && segment.count > 0 ? ( + + ) : null} + + {idx < items.length - 1 ? : null} +
+ ))} +
+ ); +}; diff --git a/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/index.ts b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/index.ts new file mode 100644 index 000000000..e92a4d6c3 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/StatusBreakdownPanel/index.ts @@ -0,0 +1,2 @@ +export { StatusBreakdownPanel } from "./StatusBreakdownPanel"; +export type { StatusBreakdownItem } from "./StatusBreakdownPanel"; diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.stories.tsx b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.stories.tsx new file mode 100644 index 000000000..10e16b5cc --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.stories.tsx @@ -0,0 +1,215 @@ +import { useMemo } from "react"; +import { create } from "@bufbuild/protobuf"; +import type { Meta, StoryObj } from "@storybook/react"; +import { TemperaturePanel } from "./TemperaturePanel"; +import { + type TemperatureStatusCount, + TemperatureStatusCountSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { durationToHours } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils"; +import { type FleetDuration, fleetDurations } from "@/shared/components/DurationSelector"; + +// Helper to create mock temperature status counts +const createMockTemperatureStatusCount = ( + timestampSeconds: number, + coldCount: number, + okCount: number, + hotCount: number, + criticalCount: number, +): TemperatureStatusCount => { + return create(TemperatureStatusCountSchema, { + timestamp: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + coldCount, + okCount, + hotCount, + criticalCount, + }); +}; + +// Mock TemperaturePanel component for Storybook +interface MockTemperaturePanelProps { + duration: FleetDuration; + coldCount: number; + okCount: number; + hotCount: number; + criticalCount: number; + isLoading?: boolean; // Used to set temperatureStatusCounts to undefined +} + +function MockTemperaturePanel({ + duration, + coldCount, + okCount, + hotCount, + criticalCount, + isLoading = false, +}: MockTemperaturePanelProps) { + // Generate multiple data points across the time range + const temperatureStatusCounts = useMemo(() => { + const durationHours = durationToHours(duration); + const intervalCount = 12; + const intervalHours = durationHours / intervalCount; + + const counts: TemperatureStatusCount[] = []; + // eslint-disable-next-line react-hooks/purity + const now = Math.floor(Date.now() / 1000); + + // Create data points for each interval + for (let i = 0; i < intervalCount; i++) { + const hoursAgo = durationHours - i * intervalHours; + const timestampSeconds = now - Math.floor(hoursAgo * 3600); + + // For the most recent bar, use the exact props + // For historical bars, show all OK temps + const isLatestBar = i === intervalCount - 1; + const totalCount = coldCount + okCount + hotCount + criticalCount; + const barColdCount = isLatestBar ? coldCount : 0; + const barOkCount = isLatestBar ? okCount : totalCount; + const barHotCount = isLatestBar ? hotCount : 0; + const barCriticalCount = isLatestBar ? criticalCount : 0; + + counts.push( + createMockTemperatureStatusCount(timestampSeconds, barColdCount, barOkCount, barHotCount, barCriticalCount), + ); + } + + return counts; + }, [duration, coldCount, okCount, hotCount, criticalCount]); + + return ( + + ); +} + +const meta = { + title: "Proto Fleet/Dashboard/TemperaturePanel", + component: MockTemperaturePanel, + tags: ["autodocs"], + parameters: { + layout: "padded", + docs: { + description: { + component: + "Temperature monitoring panel that displays the distribution of miners across different temperature ranges (Cold, Normal, Hot, Critical) using the SegmentedMetricPanel.", + }, + }, + }, + decorators: [ + (Story) => ( +
+
+ +
+
+ ), + ], + argTypes: { + duration: { + control: "select", + options: fleetDurations, + description: "Time range for the temperature data", + }, + coldCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners running cold", + }, + okCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners at healthy temperature", + }, + hotCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners running hot", + }, + criticalCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners at critical temperature", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default 24-hour view with typical temperature distribution. + * Shows mostly healthy temps with a few hot miners. + */ +export const Default: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 8, + hotCount: 2, + criticalCount: 0, + }, +}; + +/** + * Loading state showing skeleton loaders. + */ +export const Loading: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 0, + hotCount: 0, + criticalCount: 0, + isLoading: true, + }, +}; + +/** + * All miners at healthy temperature - ideal state. + */ +export const AllHealthy: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 10, + hotCount: 0, + criticalCount: 0, + }, +}; + +/** + * Some miners running hot - warning state. + */ +export const HighTemperatureWarning: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 7, + hotCount: 3, + criticalCount: 0, + }, +}; + +/** + * Critical temperature alert - some miners overheating. + */ +export const CriticalTemperature: Story = { + args: { + duration: "24h", + coldCount: 0, + okCount: 6, + hotCount: 2, + criticalCount: 2, + }, +}; + +/** + * Mixed temperature distribution across all ranges. + */ +export const MixedDistribution: Story = { + args: { + duration: "24h", + coldCount: 1, + okCount: 6, + hotCount: 2, + criticalCount: 1, + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.tsx b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.tsx new file mode 100644 index 000000000..2756bfd86 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/TemperaturePanel.tsx @@ -0,0 +1,81 @@ +import { generateTemperatureHeadline } from "./utils"; +import { type TemperatureStatusCount } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { SegmentedMetricPanel } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel"; +import type { SegmentConfig } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; +import { Triangle } from "@/shared/assets/icons"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +// Temperature segment configuration +const temperatureSegmentConfig: SegmentConfig = { + cold: { + color: "var(--color-intent-info-fill)", + label: "Cold", + displayInBreakdown: true, + showButton: false, + index: 2, + }, + ok: { + color: "var(--color-intent-info-20)", + label: "Healthy", + displayInBreakdown: true, + index: 3, + showButton: false, + }, + hot: { + color: "var(--color-intent-warning-fill)", + label: "Hot", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + critical: { + color: "var(--color-intent-critical-fill)", + label: "Critical", + displayInBreakdown: true, + showButton: false, + icon: , + index: 0, + buttonVariant: "primary", // Use primary button for critical items + }, +}; + +interface TemperaturePanelProps { + duration: FleetDuration; + /** Temperature status counts — undefined = not loaded yet */ + temperatureStatusCounts: TemperatureStatusCount[] | undefined; +} + +export function TemperaturePanel({ duration, temperatureStatusCounts }: TemperaturePanelProps) { + if (temperatureStatusCounts === undefined) { + const stat = { + label: "Temperature", + value: undefined, + units: "", + }; + + return ( +
+ + + +
+ + + +
+
+ ); + } + + return ( + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/index.ts b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/index.ts new file mode 100644 index 000000000..bb9c4e0eb --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/index.ts @@ -0,0 +1 @@ +export { TemperaturePanel } from "./TemperaturePanel"; diff --git a/client/src/protoFleet/features/dashboard/components/TemperaturePanel/utils.ts b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/utils.ts new file mode 100644 index 000000000..37b60e03c --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/TemperaturePanel/utils.ts @@ -0,0 +1,30 @@ +import type { SegmentedBarChartData } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; + +/** + * Generate temperature-specific headline based on processed data + * @param processedData - Array of arrays of processed chart data (multi-day format) + * @returns Formatted headline string + */ +export const generateTemperatureHeadline = (processedData: SegmentedBarChartData[][]): string => { + // Flatten all data points across all charts + const allDataPoints = processedData.flat(); + + if (allDataPoints.length === 0) { + return "No data"; + } + + // Get the most recent data point + const latestPoint = allDataPoints[allDataPoints.length - 1]; + + // Calculate miners outside safe range (everything except 'ok') + const outsideSafeRange = (latestPoint.cold || 0) + (latestPoint.hot || 0) + (latestPoint.critical || 0); + + if (outsideSafeRange > 0) { + // There are miners outside safe range + const minerText = outsideSafeRange === 1 ? "miner" : "miners"; + return `${outsideSafeRange} ${minerText} outside of safe range`; + } + + // All miners are healthy + return "All miners within optimal range"; +}; diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.stories.tsx b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.stories.tsx new file mode 100644 index 000000000..8cd68257a --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.stories.tsx @@ -0,0 +1,255 @@ +import { useMemo } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { create } from "@bufbuild/protobuf"; +import type { Meta, StoryObj } from "@storybook/react"; +import { UptimePanel } from "./UptimePanel"; +import { type UptimeStatusCount, UptimeStatusCountSchema } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { durationToHours } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/utils"; +import { type FleetDuration, fleetDurations } from "@/shared/components/DurationSelector"; + +// Helper to create mock uptime status counts +const createMockUptimeStatusCount = ( + timestampSeconds: number, + hashingCount: number, + notHashingCount: number, +): UptimeStatusCount => { + return create(UptimeStatusCountSchema, { + timestamp: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + hashingCount, + notHashingCount, + }); +}; + +// Mock UptimePanel component for Storybook +interface MockUptimePanelProps { + duration: FleetDuration; + hashingCount: number; + notHashingCount: number; + isLoading?: boolean; // Used to set uptimeStatusCounts to undefined +} + +function MockUptimePanel({ duration, hashingCount, notHashingCount, isLoading = false }: MockUptimePanelProps) { + // Generate multiple data points across the time range to show a proper chart + const uptimeStatusCounts = useMemo(() => { + const durationHours = durationToHours(duration); + const intervalCount = 12; // Match the number of bars in the chart + const intervalHours = durationHours / intervalCount; + + const counts: UptimeStatusCount[] = []; + // eslint-disable-next-line react-hooks/purity + const now = Math.floor(Date.now() / 1000); + const totalMiners = hashingCount + notHashingCount; + + // Create data points for each interval + // Historical bars show 100% hashing, most recent bar shows actual state + for (let i = 0; i < intervalCount; i++) { + const hoursAgo = durationHours - i * intervalHours; + const timestampSeconds = now - Math.floor(hoursAgo * 3600); + + // For the most recent bar (i === intervalCount - 1), use the exact props + // For historical bars, show all miners hashing + const isLatestBar = i === intervalCount - 1; + const barHashingCount = isLatestBar ? hashingCount : totalMiners; + const barNotHashingCount = isLatestBar ? notHashingCount : 0; + + counts.push(createMockUptimeStatusCount(timestampSeconds, barHashingCount, barNotHashingCount)); + } + + return counts; + }, [duration, hashingCount, notHashingCount]); + + return ; +} + +const meta = { + title: "Proto Fleet/Dashboard/UptimePanel", + component: MockUptimePanel, + tags: ["autodocs"], + parameters: { + withRouter: false, + layout: "padded", + docs: { + description: { + component: + "Uptime monitoring panel that displays the distribution of miners between hashing and not hashing states using the SegmentedMetricPanel. Shows real-time streaming updates of miner uptime status.", + }, + }, + }, + decorators: [ + (Story) => ( + +
+
+ +
+
+
+ ), + ], + argTypes: { + duration: { + control: "select", + options: fleetDurations, + description: "Time range for the uptime data", + }, + hashingCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners currently hashing", + }, + notHashingCount: { + control: { type: "number", min: 0, max: 100 }, + description: "Number of miners not currently hashing", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default 24-hour view with typical uptime data. + * Shows 8 miners hashing and 2 not hashing (20% downtime). + */ +export const Default: Story = { + args: { + duration: "24h", + hashingCount: 8, + notHashingCount: 2, + }, +}; + +/** + * Loading state showing skeleton loaders while data is being fetched. + */ +export const Loading: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 0, + isLoading: true, + }, +}; + +/** + * No data state - shown when there is no telemetry data available. + * Displays "No data" message. + */ +export const NoData: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 0, + }, + render: (args) => { + return ; + }, +}; + +/** + * No miners state - shown when there are 0 total miners. + * Displays "No miners" message. + */ +export const NoMiners: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 0, + }, +}; + +/** + * All miners not hashing - critical state with 100% downtime. + * Shows "100% not hashing" headline with action button. + */ +export const AllNotHashing: Story = { + args: { + duration: "24h", + hashingCount: 0, + notHashingCount: 10, + }, +}; + +/** + * All miners are hashing - ideal state with 100% uptime. + * Shows "All miners hashing" headline with no action button. + */ +export const AllHashing: Story = { + args: { + duration: "24h", + hashingCount: 10, + notHashingCount: 0, + }, +}; + +/** + * One miner not hashing - shows singular "1 miner" text. + * Displays action button with count and percentage (10%). + */ +export const OneMinerDown: Story = { + args: { + duration: "24h", + hashingCount: 9, + notHashingCount: 1, + }, +}; + +/** + * Multiple miners not hashing - shows plural "miners" text. + * Displays "2 miners not hashing (20%)" with action button. + */ +export const MultipleMinersDown: Story = { + args: { + duration: "24h", + hashingCount: 8, + notHashingCount: 2, + }, +}; + +/** + * Significant downtime - half the fleet not hashing. + * Shows "5 miners not hashing (50%)" in critical state. + */ +export const SignificantDowntime: Story = { + args: { + duration: "24h", + hashingCount: 5, + notHashingCount: 5, + }, +}; + +/** + * Large fleet with some miners down. + * Demonstrates scaling with 50 miners total. + */ +export const LargeFleet: Story = { + args: { + duration: "24h", + hashingCount: 45, + notHashingCount: 5, + }, +}; + +/** + * 7-day view showing uptime trends over a full week. + */ +export const OneWeek: Story = { + args: { + duration: "7d", + hashingCount: 8, + notHashingCount: 2, + }, +}; + +/** + * 30-day view showing uptime patterns over a month. + */ +export const ThirtyDays: Story = { + args: { + duration: "30d", + hashingCount: 8, + notHashingCount: 2, + }, +}; diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.test.tsx b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.test.tsx new file mode 100644 index 000000000..ffbbc2d22 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import userEvent from "@testing-library/user-event"; +import { UptimePanel } from "./UptimePanel"; +import { type UptimeStatusCount, UptimeStatusCountSchema } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +// Mock react-router-dom +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", () => ({ + useNavigate: () => mockNavigate, +})); + +// Helper function to create proper UptimeStatusCount objects +const createMockUptimeStatusCount = ( + timestampSeconds: number, + hashingCount: number, + notHashingCount: number, +): UptimeStatusCount => { + return create(UptimeStatusCountSchema, { + timestamp: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + hashingCount, + notHashingCount, + }); +}; + +describe("UptimePanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockNavigate.mockClear(); + }); + + it("renders loading state", () => { + // undefined = not loaded yet (loading state) + render(); + + // Check for skeleton loading state + expect(screen.getByText("Uptime")).toBeInTheDocument(); + }); + + it("renders with all miners hashing", () => { + // Use timestamp from 1 hour ago to ensure it's before chart intervals + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 5, 0), + ]; + + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + expect(screen.getByText("Not hashing")).toBeInTheDocument(); + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.getByText("0% of fleet")).toBeInTheDocument(); + expect(screen.getByText("100% of fleet")).toBeInTheDocument(); + // Button should not be shown when all miners are hashing + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("renders with some miners not hashing", () => { + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 4, 1), + ]; + + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("20% not hashing")).toBeInTheDocument(); + expect(screen.getByText("Not hashing")).toBeInTheDocument(); + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.getByText("20% of fleet")).toBeInTheDocument(); + expect(screen.getByText("80% of fleet")).toBeInTheDocument(); + // Button should show with singular "miner" + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("1 miner")).toBeInTheDocument(); + }); + + it("renders with multiple miners not hashing", () => { + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 3, 2), + ]; + + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("40% not hashing")).toBeInTheDocument(); + expect(screen.getByText("Not hashing")).toBeInTheDocument(); + expect(screen.getByText("Hashing")).toBeInTheDocument(); + expect(screen.getByText("40% of fleet")).toBeInTheDocument(); + expect(screen.getByText("60% of fleet")).toBeInTheDocument(); + // Button should show with plural "miners" + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("2 miners")).toBeInTheDocument(); + }); + + it("shows button only when not hashing count > 0", () => { + const uptimeStatusCountsAllHashing: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 5, 0), + ]; + + const { rerender } = render(); + + // Should not show button when count is 0 + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + // Update with not hashing miners + const uptimeStatusCountsWithNotHashing: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 4, 1), + ]; + + rerender(); + + // Should show button with count when not hashing > 0 + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.getByText("1 miner")).toBeInTheDocument(); + expect(screen.getByText("20% not hashing")).toBeInTheDocument(); + }); + + it("handles empty data", () => { + render(); + + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("No data")).toBeInTheDocument(); + }); + + it("handles different duration props", () => { + // Use timestamp from 3 days ago to work with all durations including 5d + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3 * 24 * 3600, 5, 0), + ]; + + const { rerender } = render(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Uptime")).toBeInTheDocument(); + expect(screen.getByText("All miners hashing")).toBeInTheDocument(); + }); + + it("navigates to miners page with filters when clicking not hashing button", async () => { + const user = userEvent.setup(); + const uptimeStatusCounts: UptimeStatusCount[] = [ + createMockUptimeStatusCount(Math.floor(Date.now() / 1000) - 3600, 4, 1), + ]; + + render(); + + // Find and click the "1 miner" button + const button = screen.getByRole("button", { name: /1 miner/i }); + await user.click(button); + + // Verify navigate was called with the correct URL + expect(mockNavigate).toHaveBeenCalledWith("/miners?status=offline,sleeping,needs-attention"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.tsx b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.tsx new file mode 100644 index 000000000..4ab90ea68 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/UptimePanel.tsx @@ -0,0 +1,75 @@ +import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { generateUptimeHeadline } from "./utils"; +import { type UptimeStatusCount } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import ChartWidget from "@/protoFleet/features/dashboard/components/ChartWidget"; +import { SegmentedMetricPanel } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel"; +import type { SegmentConfig } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; +import { FleetDuration } from "@/shared/components/DurationSelector"; +import SkeletonBar from "@/shared/components/SkeletonBar"; + +interface UptimePanelProps { + duration: FleetDuration; + /** Uptime status counts — undefined = not loaded yet */ + uptimeStatusCounts: UptimeStatusCount[] | undefined; +} + +export function UptimePanel({ duration, uptimeStatusCounts }: UptimePanelProps) { + const navigate = useNavigate(); + + // Uptime segment configuration with navigation handler + const uptimeSegmentConfig: SegmentConfig = useMemo( + () => ({ + hashing: { + color: "var(--color-text-primary)", + label: "Hashing", + displayInBreakdown: true, + showButton: false, + index: 1, + }, + notHashing: { + color: "var(--color-core-primary-10)", + label: "Not hashing", + displayInBreakdown: true, + showButton: true, + buttonVariant: "secondary", + index: 0, + onClick: () => { + // Navigate to miners page with offline, sleeping, and needs-attention status filters + navigate("/miners?status=offline,sleeping,needs-attention"); + }, + }, + }), + [navigate], + ); + + if (uptimeStatusCounts === undefined) { + const stat = { + label: "Uptime", + value: undefined, + units: "", + }; + + return ( +
+ + + +
+ + +
+
+ ); + } + + return ( + + ); +} diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/index.ts b/client/src/protoFleet/features/dashboard/components/UptimePanel/index.ts new file mode 100644 index 000000000..e031b88ba --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/index.ts @@ -0,0 +1 @@ +export { UptimePanel } from "./UptimePanel"; diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.test.ts b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.test.ts new file mode 100644 index 000000000..7993be8dc --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vitest"; +import { generateUptimeHeadline } from "./utils"; +import type { SegmentedBarChartData } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; + +describe("generateUptimeHeadline", () => { + it("returns 'No data' when no data points are provided", () => { + const result = generateUptimeHeadline([]); + expect(result).toBe("No data"); + }); + + it("returns 'No data' when empty array of arrays is provided", () => { + const result = generateUptimeHeadline([[]]); + expect(result).toBe("No data"); + }); + + it("returns 'All miners hashing' when all miners are hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 5, + notHashing: 0, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("All miners hashing"); + }); + + it("returns percentage when only one miner is not hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 4, + notHashing: 1, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("20% not hashing"); + }); + + it("returns percentage when multiple miners are not hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("40% not hashing"); + }); + + it("calculates correct percentage for not hashing miners", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 97, + notHashing: 3, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("3% not hashing"); + }); + + it("rounds percentage to nearest integer", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 6, + notHashing: 1, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + // 1/7 = 14.28%, should round to 14% + expect(result).toBe("14% not hashing"); + }); + + it("uses the most recent data point from multiple data points", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 10000, + hashing: 5, + notHashing: 0, + }, + { + datetime: Date.now() - 5000, + hashing: 4, + notHashing: 1, + }, + { + datetime: Date.now(), + hashing: 3, + notHashing: 2, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("40% not hashing"); + }); + + it("flattens multi-day data and uses the last point", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now() - 20000, + hashing: 5, + notHashing: 0, + }, + ], + [ + { + datetime: Date.now() - 10000, + hashing: 4, + notHashing: 1, + }, + ], + [ + { + datetime: Date.now(), + hashing: 2, + notHashing: 3, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("60% not hashing"); + }); + + it("returns 'No miners' when total count is 0", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 0, + notHashing: 0, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("No miners"); + }); + + it("handles undefined notHashing field", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 5, + // notHashing is undefined + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("All miners hashing"); + }); + + it("handles undefined hashing field", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + // hashing is undefined + notHashing: 2, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("100% not hashing"); + }); + + it("handles 100% not hashing", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 0, + notHashing: 5, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("100% not hashing"); + }); + + it("handles large numbers of miners", () => { + const data: SegmentedBarChartData[][] = [ + [ + { + datetime: Date.now(), + hashing: 950, + notHashing: 50, + }, + ], + ]; + + const result = generateUptimeHeadline(data); + expect(result).toBe("5% not hashing"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.ts b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.ts new file mode 100644 index 000000000..56f8aad14 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/components/UptimePanel/utils.ts @@ -0,0 +1,35 @@ +import type { SegmentedBarChartData } from "@/protoFleet/features/dashboard/components/SegmentedMetricPanel/types"; + +/** + * Generate uptime-specific headline based on processed data + * @param processedData - Array of arrays of processed chart data (multi-day format) + * @returns Formatted headline string + */ +export const generateUptimeHeadline = (processedData: SegmentedBarChartData[][]): string => { + // Flatten all data points across all charts + const allDataPoints = processedData.flat(); + + if (allDataPoints.length === 0) { + return "No data"; + } + + // Get the most recent data point + const latestPoint = allDataPoints[allDataPoints.length - 1]; + + const notHashingCount = latestPoint.notHashing || 0; + const totalMiners = (latestPoint.hashing || 0) + notHashingCount; + + if (totalMiners === 0) { + return "No miners"; + } + + if (notHashingCount === 0) { + // All miners are hashing + return "All miners hashing"; + } + + // Calculate percentage of miners not hashing + const notHashingPercentage = Math.round((notHashingCount / totalMiners) * 100); + + return `${notHashingPercentage}% not hashing`; +}; diff --git a/client/src/protoFleet/features/dashboard/constants.ts b/client/src/protoFleet/features/dashboard/constants.ts new file mode 100644 index 000000000..2044b436c --- /dev/null +++ b/client/src/protoFleet/features/dashboard/constants.ts @@ -0,0 +1,2 @@ +export const dangerInactivePercentage = 5; +export const dangerOfflinePercentage = 5; diff --git a/client/src/protoFleet/features/dashboard/index.ts b/client/src/protoFleet/features/dashboard/index.ts new file mode 100644 index 000000000..2f27e2174 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/index.ts @@ -0,0 +1,10 @@ +// Dashboard pages +export { default as Dashboard } from "./pages/Dashboard"; + +// Dashboard components +export { default as ChartWidget } from "./components/ChartWidget"; +export { default as FleetHealth } from "./components/FleetHealth"; +export { default as SectionHeading } from "./components/SectionHeading"; + +// Types +export type { AggregateStats, StatsArgs, TimeSeriesDataPoint, Value } from "./types"; diff --git a/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx b/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx new file mode 100644 index 000000000..5509c00ba --- /dev/null +++ b/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx @@ -0,0 +1,166 @@ +import { useMemo } from "react"; +import { MeasurementType, type Metric } from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; +import { useComponentErrors } from "@/protoFleet/api/useComponentErrors"; +import useFleetCounts from "@/protoFleet/api/useFleetCounts"; +import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; +import { useTelemetryMetrics } from "@/protoFleet/api/useTelemetryMetrics"; +import { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; +import { EfficiencyPanel } from "@/protoFleet/features/dashboard/components/EfficiencyPanel"; +import FleetHealth from "@/protoFleet/features/dashboard/components/FleetHealth"; +import { HashratePanel } from "@/protoFleet/features/dashboard/components/HashratePanel"; +import { PowerPanel } from "@/protoFleet/features/dashboard/components/PowerPanel"; +import SectionHeading from "@/protoFleet/features/dashboard/components/SectionHeading"; +import { TemperaturePanel } from "@/protoFleet/features/dashboard/components/TemperaturePanel"; +import { UptimePanel } from "@/protoFleet/features/dashboard/components/UptimePanel"; +import FleetErrors from "@/protoFleet/features/kpis/components/FleetErrors"; +import { MinersPage } from "@/protoFleet/features/onboarding"; +import { CompleteSetup } from "@/protoFleet/features/onboarding/components/CompleteSetup"; +import { useDuration, useSetDuration } from "@/protoFleet/store"; +import DurationSelector, { fleetDurations } from "@/shared/components/DurationSelector"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import { useStickyState } from "@/shared/hooks/useStickyState"; +import { buildVersionInfo } from "@/shared/utils/version"; + +// Constants for telemetry options - stable references to prevent unnecessary re-renders +const ALL_DEVICES: string[] = []; +const ALL_MEASUREMENT_TYPES: MeasurementType[] = [ + MeasurementType.HASHRATE, + MeasurementType.POWER, + MeasurementType.TEMPERATURE, + MeasurementType.EFFICIENCY, + MeasurementType.UPTIME, +]; + +const Dashboard = () => { + const { devicePaired, statusLoaded } = useOnboardedStatus(); + const duration = useDuration(); + const setDuration = useSetDuration(); + const currentYear = new Date().getFullYear(); + const { refs } = useStickyState(); + + // Fleet counts — polled for fresh minerStateCounts + const { totalMiners, stateCounts, hasLoaded: countsLoaded } = useFleetCounts({ pollIntervalMs: POLL_INTERVAL_MS }); + + // Component errors — polled, local state (no store) + const { controlBoardErrors, fanErrors, hashboardErrors, psuErrors } = useComponentErrors({ + pollIntervalMs: POLL_INTERVAL_MS, + }); + + // Combined telemetry — polled, replaces data each cycle (no streaming merge) + const telemetryOptions = useMemo( + () => ({ + deviceIds: ALL_DEVICES, + measurementTypes: ALL_MEASUREMENT_TYPES, + duration, + enabled: true, + pollIntervalMs: POLL_INTERVAL_MS, + }), + [duration], + ); + + const { data: telemetryData } = useTelemetryMetrics(telemetryOptions); + + // Extract metrics for panels — filter by measurement type + const allMetrics = telemetryData?.metrics; + const hashrateMetrics = useMemo( + () => allMetrics?.filter((m: Metric) => m.measurementType === MeasurementType.HASHRATE), + [allMetrics], + ); + const powerMetrics = useMemo( + () => allMetrics?.filter((m: Metric) => m.measurementType === MeasurementType.POWER), + [allMetrics], + ); + const efficiencyMetrics = useMemo( + () => allMetrics?.filter((m: Metric) => m.measurementType === MeasurementType.EFFICIENCY), + [allMetrics], + ); + const temperatureStatusCounts = telemetryData?.temperatureStatusCounts; + const uptimeStatusCounts = telemetryData?.uptimeStatusCounts; + + if (!statusLoaded) { + return ( +
+ +
+ ); + } + + return ( +
+ ); +}; + +export default Dashboard; diff --git a/client/src/protoFleet/features/dashboard/types.ts b/client/src/protoFleet/features/dashboard/types.ts new file mode 100644 index 000000000..1e356dee0 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/types.ts @@ -0,0 +1,39 @@ +import { FleetDuration } from "@/shared/components/DurationSelector"; + +export type Value = number | null; + +export type AggregateStats = { + avg?: Value; + max?: Value; + min?: Value; +}; + +export type TimeSeriesDataPoint = { + datetime: number; + value: Value; +}; + +export type StatsArgs = AggregateStats & { lowestPerformer?: string }; + +/** + * ProtoFleet specific outlet context for KPI data + */ +export interface KpiOutletContext { + duration: FleetDuration; + minerHashrate: { + hashrate: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; + minerEfficiency: { + efficiency: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; + minerPowerUsage: { + powerUsage: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; + minerTemperature: { + temperature: TimeSeriesDataPoint[]; + aggregates: AggregateStats; + }; +} diff --git a/client/src/protoFleet/features/dashboard/utils/chartDataPadding.test.ts b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.test.ts new file mode 100644 index 000000000..c913608a5 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { padChartDataWithNulls } from "./chartDataPadding"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +describe("padChartDataWithNulls", () => { + it("should return empty array when input is empty", () => { + const result = padChartDataWithNulls([], "1h"); + expect(result).toEqual([]); + }); + + it("should pad data with null values for missing timestamps", () => { + const now = Date.now(); + const thirtyMinutesAgo = now - 1800 * 1000; + + // Simulate having only 2 data points in the last hour + const data: ChartData[] = [ + { datetime: thirtyMinutesAgo, hashrate: 100 }, + { datetime: now, hashrate: 150 }, + ]; + + const result = padChartDataWithNulls(data, "1h"); + + // Should have many more points (1 hour / 90 seconds = 40 buckets) + expect(result.length).toBeGreaterThan(2); + expect(result.length).toBeLessThanOrEqual(41); // 3600 / 90 = 40, plus potential rounding + + // First points should be null (no data in the first part of the hour) + const firstNullPoint = result.find((point) => point.datetime < thirtyMinutesAgo); + expect(firstNullPoint).toBeDefined(); + expect(firstNullPoint?.hashrate).toBeNull(); + + // Original data points should be preserved + const preservedPoints = result.filter((point) => point.hashrate !== null); + expect(preservedPoints.length).toBeGreaterThanOrEqual(2); + }); + + it("should preserve all numeric fields from original data", () => { + const now = Date.now(); + + interface MultiMetricData extends ChartData { + hashrate: number | null; + power: number | null; + efficiency: number | null; + } + + const data: MultiMetricData[] = [{ datetime: now, hashrate: 100, power: 2000, efficiency: 50 }]; + + const result = padChartDataWithNulls(data, "1h"); + + // Check that a null point has all the same fields + const nullPoint = result.find((point) => point.hashrate === null); + expect(nullPoint).toBeDefined(); + expect(nullPoint).toHaveProperty("hashrate"); + expect(nullPoint).toHaveProperty("power"); + expect(nullPoint).toHaveProperty("efficiency"); + expect(nullPoint?.hashrate).toBeNull(); + expect(nullPoint?.power).toBeNull(); + expect(nullPoint?.efficiency).toBeNull(); + }); + + it("should handle different duration strings correctly with dynamic granularity", () => { + const now = Date.now(); + + const data: ChartData[] = [{ datetime: now, hashrate: 100 }]; + + // 1 hour should have ~40 buckets (3600s / 90s granularity) + const result1h = padChartDataWithNulls(data, "1h"); + expect(result1h.length).toBeGreaterThan(30); + expect(result1h.length).toBeLessThanOrEqual(50); + + // 7 days use 900s granularity: 672 buckets (604800s / 900s) + const result7d = padChartDataWithNulls(data, "7d"); + expect(result7d.length).toBeGreaterThan(650); + expect(result7d.length).toBeLessThanOrEqual(1000); + + // 24 hours uses 90s granularity: ~960 buckets + const result24h = padChartDataWithNulls(data, "24h"); + expect(result24h.length).toBeGreaterThan(900); + expect(result24h.length).toBeLessThanOrEqual(1000); + }); + + it("should use 90-second granularity for short durations (1h)", () => { + const now = Date.now(); + const granularity = 90 * 1000; // 90 seconds in milliseconds for 1h duration + + const data: ChartData[] = [{ datetime: now, hashrate: 100 }]; + + const result = padChartDataWithNulls(data, "1h"); + + // Check that timestamps are at 90-second intervals for 1h duration + for (let i = 1; i < result.length; i++) { + const timeDiff = result[i].datetime - result[i - 1].datetime; + expect(timeDiff).toBe(granularity); + } + }); + + it("should match existing data to correct buckets", () => { + const now = Date.now(); + const granularity = 90 * 1000; + + // Create a timestamp that's already on a 90-second boundary + const bucketTime = Math.floor(now / granularity) * granularity; + + const data: ChartData[] = [{ datetime: bucketTime, hashrate: 100 }]; + + const result = padChartDataWithNulls(data, "1h"); + + // The data point should be preserved (not replaced with null) + const matchingPoint = result.find((point) => { + const pointBucket = Math.floor(point.datetime / granularity) * granularity; + return pointBucket === bucketTime; + }); + + expect(matchingPoint).toBeDefined(); + expect(matchingPoint?.hashrate).toBe(100); + }); + + it("should not pad beyond the last actual data point timestamp", () => { + const now = Date.now(); + const fiveMinutesAgo = now - 5 * 60 * 1000; + const tenMinutesAgo = now - 10 * 60 * 1000; + + // Create data that stops 5 minutes ago (not at current time) + const data: ChartData[] = [ + { datetime: tenMinutesAgo, hashrate: 100 }, + { datetime: fiveMinutesAgo, hashrate: 150 }, + ]; + + const result = padChartDataWithNulls(data, "1h"); + + // Get the last timestamp in the result + const lastTimestamp = result[result.length - 1].datetime; + + // The last timestamp should be close to fiveMinutesAgo (within one bucket) + // and should NOT extend to current time + const granularity = 90 * 1000; + const expectedLastBucket = Math.floor(fiveMinutesAgo / granularity) * granularity; + expect(lastTimestamp).toBe(expectedLastBucket); + + // Verify no timestamps are close to current time + const timeSinceLastPoint = now - lastTimestamp; + expect(timeSinceLastPoint).toBeGreaterThan(4 * 60 * 1000); // At least 4 minutes ago + + // Verify we don't have null datapoints at the end (after the last actual data) + const lastActualDataBucket = Math.floor(fiveMinutesAgo / granularity) * granularity; + const pointsAfterLastData = result.filter((point) => point.datetime > lastActualDataBucket); + expect(pointsAfterLastData.length).toBe(0); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/utils/chartDataPadding.ts b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.ts new file mode 100644 index 000000000..01cd5c3a8 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/chartDataPadding.ts @@ -0,0 +1,81 @@ +import { getGranularityForDuration } from "@/protoFleet/features/dashboard/utils/granularity"; +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; +import type { ChartData } from "@/shared/components/LineChart/types"; + +/** + * Pad chart data with null values for missing timestamps in the requested duration + * + * @param data - The actual chart data from the API + * @param duration - The requested time duration (e.g., "24h", "7d") + * @returns Chart data padded with null values for the full time range + * + * @example + * // If user selects 24h but only has 4h of data: + * // Returns 24h worth of datapoints, with first 20h as null values + * const paddedData = padChartDataWithNulls(chartData, "24h"); + */ +export function padChartDataWithNulls(data: T[], duration: FleetDuration): T[] { + if (!data || data.length === 0) { + return data; + } + + const durationMs = getFleetDurationMs(duration); + const granularitySeconds = getGranularityForDuration(duration); + const now = Date.now(); + const startTime = now - durationMs; + + // Find the first bucket boundary at or before startTime + const granularityMs = granularitySeconds * 1000; + const firstBucket = Math.floor(startTime / granularityMs) * granularityMs; + + // Use the last actual data point as the end boundary, not current time + // Filter out invalid datetime values and provide fallback to current time + // Safe: data.length === 0 is handled by early return above, so Math.max never receives empty array + const validTimestamps = data.map((d) => d.datetime).filter((dt) => typeof dt === "number" && !isNaN(dt)); + const lastDataTimestamp = validTimestamps.length > 0 ? Math.max(...validTimestamps) : now; + const lastBucket = Math.floor(lastDataTimestamp / granularityMs) * granularityMs; + + // Generate all expected timestamps at the appropriate granularity interval + const expectedTimestamps: number[] = []; + for (let bucketTime = firstBucket; bucketTime <= lastBucket; bucketTime += granularityMs) { + expectedTimestamps.push(bucketTime); + } + + // Create a map of existing data by timestamp + const existingDataMap = new Map(); + data.forEach((point) => { + // Round to nearest granularity bucket to match expected timestamps + const bucketTime = Math.floor(point.datetime / granularityMs) * granularityMs; + existingDataMap.set(bucketTime, point); + }); + + // Build the padded dataset + const paddedData: T[] = expectedTimestamps.map((timestamp) => { + const existingPoint = existingDataMap.get(timestamp); + + if (existingPoint) { + // Use the bucketed timestamp to ensure consistent spacing + return { ...existingPoint, datetime: timestamp }; + } + + // Create a null datapoint for this timestamp + // TypeScript needs help inferring the shape, so we use type assertion + const nullPoint: ChartData = { + datetime: timestamp, + }; + + // Add null for all numeric keys from the first data point + if (data.length > 0) { + const samplePoint = data[0]; + Object.keys(samplePoint).forEach((key) => { + if (key !== "datetime" && typeof samplePoint[key as keyof T] === "number") { + (nullPoint as any)[key] = null; + } + }); + } + + return nullPoint as T; + }); + + return paddedData; +} diff --git a/client/src/protoFleet/features/dashboard/utils/createMockMetric.ts b/client/src/protoFleet/features/dashboard/utils/createMockMetric.ts new file mode 100644 index 000000000..a7f25acfd --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/createMockMetric.ts @@ -0,0 +1,29 @@ +import { create } from "@bufbuild/protobuf"; +import { + AggregatedValueSchema, + AggregationType, + type MeasurementType, + type Metric, + MetricSchema, +} from "@/protoFleet/api/generated/telemetry/v1/telemetry_pb"; + +export const createMockMetric = ( + measurementType: MeasurementType, + avgValue: number, + timestampSeconds: number, +): Metric => { + return create(MetricSchema, { + measurementType, + openTime: { + seconds: BigInt(timestampSeconds), + nanos: 0, + }, + aggregatedValues: [ + create(AggregatedValueSchema, { + aggregationType: AggregationType.AVERAGE, + value: avgValue, + }), + ], + deviceCount: 1, + }); +}; diff --git a/client/src/protoFleet/features/dashboard/utils/granularity.ts b/client/src/protoFleet/features/dashboard/utils/granularity.ts new file mode 100644 index 000000000..5d1804651 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/granularity.ts @@ -0,0 +1,38 @@ +import { type FleetDuration, getFleetDurationMs } from "@/shared/components/DurationSelector"; + +const DEFAULT_GRANULARITY_SECONDS = 90; +const GRANULARITY_48H_SECONDS = 180; // 3 minutes +const GRANULARITY_5D_SECONDS = 600; // 10 minutes +const GRANULARITY_7D_SECONDS = 900; // 15 minutes (672 buckets for 7d, aligned with hourly aggregates) +const GRANULARITY_30D_SECONDS = 2700; // 45 minutes (~960 buckets for 30d) +const GRANULARITY_90D_SECONDS = 8100; // 2.25 hours (~960 buckets for 90d) +const GRANULARITY_1Y_SECONDS = 32850; // ~9 hours (~960 buckets for 1y) + +const HOURS_48_IN_SECONDS = 48 * 3600; +const DAYS_5_IN_SECONDS = 5 * 24 * 3600; +const DAYS_7_IN_SECONDS = 7 * 24 * 3600; +const DAYS_30_IN_SECONDS = 30 * 24 * 3600; +const DAYS_90_IN_SECONDS = 90 * 24 * 3600; +const DAYS_365_IN_SECONDS = 365 * 24 * 3600; + +/** + * Calculate optimal granularity based on duration to stay within backend LIMIT. + * Backend has LIMIT of 1000 buckets, so we adjust granularity for longer durations. + * + * Note: These thresholds are intentionally different from backend data source selection + * (raw ≤24h, hourly 24h-10d, daily >10d). The backend data source determines WHICH table + * to query for performance, while this granularity controls HOW MANY buckets to return. + * The backend aggregates its chosen data source to match this requested granularity. + */ +export const getGranularityForDuration = (duration: FleetDuration): number => { + const totalSeconds = getFleetDurationMs(duration) / 1000; + + // Granularity thresholds ensure ~960 buckets max for chart rendering performance + if (totalSeconds >= DAYS_365_IN_SECONDS) return GRANULARITY_1Y_SECONDS; // 1y -> ~9 hours + if (totalSeconds >= DAYS_90_IN_SECONDS) return GRANULARITY_90D_SECONDS; // 90d -> 2.25 hours + if (totalSeconds >= DAYS_30_IN_SECONDS) return GRANULARITY_30D_SECONDS; // 30d -> 45 min + if (totalSeconds >= DAYS_7_IN_SECONDS) return GRANULARITY_7D_SECONDS; // 7d -> 15 min + if (totalSeconds >= DAYS_5_IN_SECONDS) return GRANULARITY_5D_SECONDS; // 5d -> 10 min + if (totalSeconds >= HOURS_48_IN_SECONDS) return GRANULARITY_48H_SECONDS; // 48h -> 3 min + return DEFAULT_GRANULARITY_SECONDS; // Default for shorter durations +}; diff --git a/client/src/protoFleet/features/dashboard/utils/metricNormalization.test.ts b/client/src/protoFleet/features/dashboard/utils/metricNormalization.test.ts new file mode 100644 index 000000000..561bd7205 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/metricNormalization.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { normalizeEfficiencyToJTH, normalizeHashrateToTHs, normalizePowerToKW } from "./metricNormalization"; + +describe("metricNormalization", () => { + describe("normalizeEfficiencyToJTH", () => { + it("keeps already-normalized J/TH values unchanged", () => { + expect(normalizeEfficiencyToJTH(24.4)).toBe(24.4); + }); + + it("converts raw J/H values to J/TH", () => { + expect(normalizeEfficiencyToJTH(24.4e-12)).toBeCloseTo(24.4); + }); + + it("converts accidentally over-converted efficiency values back to J/TH", () => { + expect(normalizeEfficiencyToJTH(24.4e12)).toBeCloseTo(24.4); + }); + }); + + describe("normalizePowerToKW", () => { + it("keeps already-normalized kW values unchanged", () => { + expect(normalizePowerToKW(3.6, 1)).toBe(3.6); + }); + + it("converts raw W values to kW", () => { + expect(normalizePowerToKW(3600, 1)).toBe(3.6); + }); + + it("keeps low valid kW values unchanged", () => { + expect(normalizePowerToKW(0.0036, 1)).toBe(0.0036); + }); + + it("skips normalization when deviceCount is invalid", () => { + expect(normalizePowerToKW(3600, 0)).toBe(3600); + expect(normalizePowerToKW(3600, NaN)).toBe(3600); + }); + }); + + describe("normalizeHashrateToTHs", () => { + it("keeps already-normalized TH/s values unchanged", () => { + expect(normalizeHashrateToTHs(120, 1)).toBe(120); + }); + + it("converts raw H/s values to TH/s", () => { + expect(normalizeHashrateToTHs(120e12, 1)).toBe(120); + }); + + it("keeps low valid TH/s values unchanged", () => { + expect(normalizeHashrateToTHs(120e-12, 1)).toBe(120e-12); + }); + + it("skips normalization when deviceCount is invalid", () => { + expect(normalizeHashrateToTHs(120e12, 0)).toBe(120e12); + expect(normalizeHashrateToTHs(120e12, NaN)).toBe(120e12); + }); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/utils/metricNormalization.ts b/client/src/protoFleet/features/dashboard/utils/metricNormalization.ts new file mode 100644 index 000000000..fe170bbf0 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/metricNormalization.ts @@ -0,0 +1,57 @@ +/** + * Normalizes telemetry metric values into dashboard display units. + * + * The API should already return display units, but older/mixed datasets can + * contain raw storage units. These guards keep charts resilient across both. + */ + +const HASHRATE_RAW_PER_DEVICE_THRESHOLD_HS = 1e9; // raw H/s is orders of magnitude larger than TH/s + +const POWER_RAW_PER_DEVICE_THRESHOLD_W = 100; // miners in raw watts are typically well above this + +const EFFICIENCY_RAW_THRESHOLD_JH = 1e-6; // raw J/H values are tiny (e.g. 24e-12) +const EFFICIENCY_OVER_CONVERTED_THRESHOLD_JTH = 1e6; // accidentally converted values become astronomically large + +const hasValidDeviceCount = (deviceCount: number): boolean => { + return Number.isFinite(deviceCount) && deviceCount > 0; +}; + +export const normalizeHashrateToTHs = (value: number, deviceCount: number): number => { + if (!Number.isFinite(value) || !hasValidDeviceCount(deviceCount)) return value; + + const perDevice = Math.abs(value) / deviceCount; + + if (perDevice > HASHRATE_RAW_PER_DEVICE_THRESHOLD_HS) { + return value / 1e12; + } + + return value; +}; + +export const normalizePowerToKW = (value: number, deviceCount: number): number => { + if (!Number.isFinite(value) || !hasValidDeviceCount(deviceCount)) return value; + + const perDevice = Math.abs(value) / deviceCount; + + if (perDevice > POWER_RAW_PER_DEVICE_THRESHOLD_W) { + return value / 1e3; + } + + return value; +}; + +export const normalizeEfficiencyToJTH = (value: number): number => { + if (!Number.isFinite(value)) return value; + + const absValue = Math.abs(value); + + if (absValue > 0 && absValue <= EFFICIENCY_RAW_THRESHOLD_JH) { + return value * 1e12; + } + + if (absValue >= EFFICIENCY_OVER_CONVERTED_THRESHOLD_JTH) { + return value / 1e12; + } + + return value; +}; diff --git a/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.test.ts b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.test.ts new file mode 100644 index 000000000..f234f10b8 --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { getMinerCountSubtitle } from "./minerCountSubtitle"; + +describe("getMinerCountSubtitle", () => { + it("returns subtitle when some miners are not reporting", () => { + const result = getMinerCountSubtitle(3, 5); + expect(result).toBe("3 of 5 miners reporting"); + }); + + it("returns subtitle when only one miner is reporting", () => { + const result = getMinerCountSubtitle(1, 10); + expect(result).toBe("1 of 10 miners reporting"); + }); + + it("returns undefined when all miners are reporting", () => { + const result = getMinerCountSubtitle(5, 5); + expect(result).toBeUndefined(); + }); + + it("returns undefined when device count equals total miners", () => { + const result = getMinerCountSubtitle(10, 10); + expect(result).toBeUndefined(); + }); + + it("returns undefined when device count is greater than total miners", () => { + const result = getMinerCountSubtitle(15, 10); + expect(result).toBeUndefined(); + }); + + it("returns undefined when device count is null", () => { + const result = getMinerCountSubtitle(null, 5); + expect(result).toBeUndefined(); + }); + + it("returns undefined when total miners is zero", () => { + const result = getMinerCountSubtitle(0, 0); + expect(result).toBeUndefined(); + }); + + it("returns undefined when total miners is negative", () => { + const result = getMinerCountSubtitle(5, -1); + expect(result).toBeUndefined(); + }); + + it("returns subtitle when zero miners are reporting", () => { + const result = getMinerCountSubtitle(0, 5); + expect(result).toBe("0 of 5 miners reporting"); + }); + + it("handles large numbers correctly", () => { + const result = getMinerCountSubtitle(999, 1000); + expect(result).toBe("999 of 1000 miners reporting"); + }); +}); diff --git a/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.ts b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.ts new file mode 100644 index 000000000..556b55b7d --- /dev/null +++ b/client/src/protoFleet/features/dashboard/utils/minerCountSubtitle.ts @@ -0,0 +1,15 @@ +/** + * Generates a subtitle showing how many miners are reporting data. + * Only returns a subtitle when not all miners are reporting. + * + * @param deviceCount - Number of miners reporting this metric + * @param totalMiners - Total number of miners in the fleet + * @returns Subtitle string or undefined if all miners are reporting + */ +export function getMinerCountSubtitle(deviceCount: number | null, totalMiners: number): string | undefined { + if (deviceCount === null || totalMiners <= 0 || deviceCount >= totalMiners) { + return undefined; + } + + return `${deviceCount} of ${totalMiners} miners reporting`; +} diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.stories.tsx new file mode 100644 index 000000000..70e8c7bb1 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.stories.tsx @@ -0,0 +1,44 @@ +import ActionBarComponent from "."; +import MinerActionsMenu from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu"; +import { Toaster as ToasterComponent } from "@/shared/features/toaster"; + +interface ActionBarArgs { + numberOfMiners: number; +} + +export const ActionBar = ({ numberOfMiners }: ActionBarArgs) => { + const selectedMiners = Array(numberOfMiners).fill("MinerId"); + + return ( +
+
+ +
+ ( + setHidden(true)} + onActionComplete={() => setHidden(false)} + /> + )} + /> +
+ ); +}; + +export default { + title: "Proto Fleet/Action Bar", + args: { + numberOfMiners: 1, + }, + argTypes: { + numberOfMiners: { + control: { type: "range", min: 1, max: 25, step: 1 }, + }, + }, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.test.tsx new file mode 100644 index 000000000..3cf3ffcc4 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import ActionBar from "."; +import MinerActionsMenu from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu"; + +// MinerActionsMenu imports hooks from the removed fleet store slice. +// Mock it so the test that renders it directly doesn't crash. +vi.mock("@/protoFleet/features/fleetManagement/components/MinerActionsMenu", () => ({ + default: ({ onActionStart }: { onActionStart?: () => void }) => ( +
+ More + + +
+ ), +})); + +vi.mock("@/protoFleet/api/usePools", () => ({ + default: () => ({ + pools: [], + validatePool: vi.fn(({ onSuccess }) => { + onSuccess?.(); + }), + validatePoolPending: false, + }), +})); + +describe("Action Bar", () => { + const actionBarTestId = "action-bar"; + + const actionBarProps = { + selectedItems: ["MAC1"], + renderActions: () =>
Action
, + }; + + const minersText = "miners selected"; + + test("renders action bar correctly", () => { + const { getByTestId, queryByText } = render(); + + const closeButton = getByTestId("close-button"); + expect(closeButton).toBeDefined(); + const minersElement = queryByText(minersText); + expect(minersElement).toBeDefined(); + + const actionButton = queryByText("Action"); + expect(actionButton).toBeDefined(); + }); + + test("renders action bar with correct number of miners", () => { + const selectedMiners = ["MAC1", "MAC2", "MAC3"]; + const { getByText } = render(); + + const element = getByText(selectedMiners.length + " miners selected"); + expect(element).toBeInTheDocument(); + }); + + test("hides action bar when there are no miners", () => { + let selectedMiners = ["MAC1"]; + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + expect(getByTestId(actionBarTestId)).toBeInTheDocument(); + + selectedMiners = []; + rerender(); + + expect(queryByTestId(actionBarTestId)).not.toBeInTheDocument(); + }); + + test("closes action bar on click of close button", () => { + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId(actionBarTestId)).toBeInTheDocument(); + const closeButton = getByTestId("close-button"); + fireEvent.click(closeButton); + + expect(queryByTestId(actionBarTestId)).not.toBeInTheDocument(); + }); + + test("renders MinerActionsMenu and calls setHidden method properly", async () => { + const onActionStartMock = vi.fn(); + const selectedMiners = ["MinerId1"]; + + const { getByText, getByTestId } = render( + ( + { + onActionStartMock(); + setHidden(true); + }} + /> + )} + />, + ); + + expect(getByText("More")).toBeInTheDocument(); + + fireEvent.click(getByTestId("actions-menu-button")); + fireEvent.click(getByTestId("reboot-popover-button")); + expect(onActionStartMock).toHaveBeenCalled(); + }); + + test("calls onClose callback when close button is clicked", () => { + const onCloseMock = vi.fn(); + const { getByTestId } = render(); + + const closeButton = getByTestId("close-button"); + fireEvent.click(closeButton); + + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + test("does not throw error when onClose is not provided", () => { + const { getByTestId } = render(); + + const closeButton = getByTestId("close-button"); + + // Should not throw error when clicking close without onClose prop + expect(() => fireEvent.click(closeButton)).not.toThrow(); + }); + + test("renders selection controls only once", () => { + const onSelectAll = vi.fn(); + + render( + + Select all + + } + />, + ); + + const controls = screen.getAllByTestId("select-all-control"); + expect(controls).toHaveLength(1); + + fireEvent.click(controls[0]); + expect(onSelectAll).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.tsx new file mode 100644 index 000000000..8c0e11621 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/ActionBar.tsx @@ -0,0 +1,101 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import clsx from "clsx"; +import { DismissTiny } from "@/shared/assets/icons"; +import Button, { variants } from "@/shared/components/Button"; +import { sizes } from "@/shared/components/ButtonGroup"; +import { type SelectionMode } from "@/shared/components/List"; + +interface ActionBarProps { + className?: string; + /** IDs of currently selected items (used for count display in "subset" mode) */ + selectedItems: string[]; + /** + * How items were selected: + * - "all": user clicked "Select All" with no filters (targets entire fleet) + * - "subset": user selected specific items or "Select All" with filters active + * - "none": no selection (ActionBar will be hidden) + * @default "subset" + */ + selectionMode?: SelectionMode; + /** + * Total number of items in the fleet. Used to display accurate count when + * selectionMode is "all", since selectedItems only contains visible page items. + */ + totalCount?: number; + selectionControls?: ReactNode; + renderActions: (setHidden: (hidden: boolean) => void) => ReactNode; + onClose?: () => void; +} + +const ActionBar = ({ + className, + selectedItems, + selectionMode = "subset", + totalCount, + selectionControls, + renderActions, + onClose, +}: ActionBarProps) => { + const [show, setShow] = useState(false); + const [hidden, setHidden] = useState(false); + + useEffect(() => { + setShow(selectedItems.length > 0); + }, [selectedItems]); + + const selectionText = useMemo(() => { + const count = selectionMode === "all" ? (totalCount ?? selectedItems.length) : selectedItems.length; + return `${count} miner${count === 1 ? "" : "s"} selected`; + }, [selectionMode, selectedItems.length, totalCount]); + + const handleClose = () => { + setShow(false); + onClose?.(); + }; + + if (!show) { + return null; + } + + const actionsClassName = clsx( + "ml-auto flex items-center justify-end gap-3", + "phone:col-start-2 phone:row-start-2 phone:ml-0 phone:justify-end", + "tablet:col-start-2 tablet:row-start-2 tablet:ml-0 tablet:justify-end", + selectionControls ? "" : "phone:col-span-2 tablet:col-span-2", + ); + + return ( +
+
+
+
+ {selectionControls ? ( +
+ {selectionControls} +
+ ) : null} +
{renderActions(setHidden)}
+
+
+ ); +}; + +export default ActionBar; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolActionsMenu.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolActionsMenu.tsx new file mode 100644 index 000000000..5f3488035 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolActionsMenu.tsx @@ -0,0 +1,92 @@ +import { useCallback, useState } from "react"; +import { Ellipsis } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Popover, { popoverSizes } from "@/shared/components/Popover"; +import { PopoverProvider, usePopover } from "@/shared/components/Popover"; +import Row from "@/shared/components/Row"; +import { positions } from "@/shared/constants"; +import { useClickOutside } from "@/shared/hooks/useClickOutside"; + +interface FleetPoolActionsMenuProps { + onTestConnection: () => void; + onRemove: () => void; + poolId: string; +} + +const FleetPoolActionsMenuInner = ({ onTestConnection, onRemove, poolId }: FleetPoolActionsMenuProps) => { + const [isOpen, setIsOpen] = useState(false); + const { triggerRef } = usePopover(); + + const onClickOutside = useCallback(() => { + setIsOpen(false); + }, []); + + useClickOutside({ + ref: triggerRef, + onClickOutside, + ignoreSelectors: [".popover-content"], + }); + + const handleTestConnection = useCallback(() => { + setIsOpen(false); + onTestConnection(); + }, [onTestConnection]); + + const handleRemove = useCallback(() => { + setIsOpen(false); + onRemove(); + }, [onRemove]); + + return ( +
+
+ ); +}; + +const FleetPoolActionsMenu = (props: FleetPoolActionsMenuProps) => ( + + + +); + +export default FleetPoolActionsMenu; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolRow.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolRow.tsx new file mode 100644 index 000000000..e70cee438 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/FleetPoolRow.tsx @@ -0,0 +1,76 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import FleetPoolActionsMenu from "./FleetPoolActionsMenu"; +import { MiningPool } from "./types"; +import { Grip } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Row from "@/shared/components/Row"; + +interface FleetPoolRowProps { + pool: MiningPool; + priorityNumber: number; + onUpdate: () => void; + onTestConnection: () => void; + onRemove: () => void; + testId?: string; +} + +const FleetPoolRow = ({ pool, priorityNumber, onUpdate, onTestConnection, onRemove, testId }: FleetPoolRowProps) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pool.poolId }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const displayTitle = pool.name || pool.poolUrl || "—"; + + return ( +
+ +
+ {/* Priority number */} +
+ {priorityNumber} +
+ + {/* Drag handle */} +
+ +
+ + {/* Pool info */} +
+
+ {displayTitle} +
+
+ {pool.poolUrl} +
+
+
+ +
+
+
+
+ ); +}; + +export default FleetPoolRow; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.stories.tsx new file mode 100644 index 000000000..53d321c89 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.stories.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; +import { action } from "storybook/actions"; +import PoolSelectionModal from "./PoolSelectionModal"; + +export default { + title: "Proto Fleet/Fleet Management/PoolSelectionModal", + component: PoolSelectionModal, +}; + +export const Default = () => { + const [open, setOpen] = useState(true); + + return ( + <> + {!open && ( +
+ +
+ )} + { + action("onDismiss")(); + setOpen(false); + }} + onSave={(selectedPoolId, poolData) => { + action("onSave")({ selectedPoolId, poolData }); + setOpen(false); + }} + /> + + ); +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.test.tsx new file mode 100644 index 000000000..0c96b7dcb --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.test.tsx @@ -0,0 +1,300 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import PoolSelectionModal from "./PoolSelectionModal"; +import { PoolSchema } from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import usePools from "@/protoFleet/api/usePools"; + +vi.mock("@/protoFleet/api/usePools"); + +describe("PoolSelectionModal", () => { + const mockPools = [ + create(PoolSchema, { + poolId: BigInt(1), + poolName: "Ocean Pool", + url: "stratum+tcp://mine.ocean.xyz:3334", + username: "ocean_user", + }), + create(PoolSchema, { + poolId: BigInt(2), + poolName: "Braiins Pool", + url: "stratum+tcp://stratum.braiins.com:3333", + username: "braiins_user", + }), + create(PoolSchema, { + poolId: BigInt(3), + poolName: "Foundry USA", + url: "stratum+tcp://stratum.foundryusapool.com:3333", + username: "foundry_user", + }), + ]; + + const mockValidatePool = vi.fn(); + const mockCreatePool = vi.fn(); + const onDismiss = vi.fn(); + const onSave = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(usePools).mockReturnValue({ + pools: mockPools, + miningPools: mockPools.map((pool) => ({ + poolId: pool.poolId.toString(), + name: pool.poolName, + poolUrl: pool.url, + username: pool.username, + })), + validatePool: mockValidatePool, + createPool: mockCreatePool, + updatePool: vi.fn(), + deletePool: vi.fn(), + validatePoolPending: false, + isLoading: false, + }); + }); + + test("renders modal with pool list", () => { + const { getByText } = render(); + + expect(getByText("Select pool")).toBeInTheDocument(); + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(getByText("Braiins Pool")).toBeInTheDocument(); + expect(getByText("Foundry USA")).toBeInTheDocument(); + }); + + test("renders search input", () => { + const { getByTestId } = render(); + + const searchInput = getByTestId("pool-search-input"); + expect(searchInput).toBeInTheDocument(); + }); + + test("autofocuses the search input on mount", () => { + const { getByTestId } = render(); + + const searchInput = getByTestId("pool-search-input"); + expect(searchInput).toHaveFocus(); + }); + + test("filters pools by name", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "ocean" } }); + + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(queryByText("Braiins Pool")).not.toBeInTheDocument(); + expect(queryByText("Foundry USA")).not.toBeInTheDocument(); + }); + + test("filters pools by URL", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "braiins.com" } }); + + expect(queryByText("Ocean Pool")).not.toBeInTheDocument(); + expect(getByText("Braiins Pool")).toBeInTheDocument(); + expect(queryByText("Foundry USA")).not.toBeInTheDocument(); + }); + + test("filters pools by username", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "foundry_user" } }); + + expect(queryByText("Ocean Pool")).not.toBeInTheDocument(); + expect(queryByText("Braiins Pool")).not.toBeInTheDocument(); + expect(getByText("Foundry USA")).toBeInTheDocument(); + }); + + test("shows 'No pools found' when search returns no results", () => { + const { getByTestId, getByText } = render(); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "nonexistent" } }); + + expect(getByText("No pools found")).toBeInTheDocument(); + }); + + test("selecting a pool and clicking Save calls onSave with pool ID", () => { + const { getByText } = render(); + + const poolRow = getByText("Ocean Pool"); + fireEvent.click(poolRow); + + const saveButton = getByText("Save"); + fireEvent.click(saveButton); + + expect(onSave).toHaveBeenCalledWith("1"); + }); + + test("Save button is disabled when no pool is selected", () => { + const { getByText } = render(); + + const saveButton = getByText("Save").closest("button"); + expect(saveButton).toBeDisabled(); + }); + + test("Save button is enabled when a pool is selected", () => { + const { getByText } = render(); + + const poolRow = getByText("Ocean Pool"); + fireEvent.click(poolRow); + + const saveButton = getByText("Save").closest("button"); + expect(saveButton).not.toBeDisabled(); + }); + + test("clicking 'Add new pool' button opens PoolModal", () => { + const { getByText } = render(); + + const addNewPoolButton = getByText("Add new pool"); + fireEvent.click(addNewPoolButton); + + expect(getByText("Save")).toBeInTheDocument(); + expect(getByText("Worker name will be appended to this username when applied to miners.")).toBeInTheDocument(); + }); + + test("rejects usernames with workername separators when adding a new pool", () => { + render(); + + fireEvent.click(screen.getByText("Add new pool")); + + fireEvent.change(screen.getByTestId("pool-name-0-input"), { target: { value: "Test Pool" } }); + fireEvent.change(screen.getByTestId("url-0-input"), { target: { value: "stratum+tcp://test.com:3333" } }); + fireEvent.change(screen.getByTestId("username-0-input"), { target: { value: "wallet.worker01" } }); + + fireEvent.click(screen.getByTestId("pool-save-button")); + + expect(mockCreatePool).not.toHaveBeenCalled(); + expect( + screen.getByText("Fleet-level pool usernames can’t include periods (.). Set worker names on each miner instead."), + ).toBeInTheDocument(); + }); + + test("renders pool data in correct columns", () => { + const { getByText } = render(); + + // Check column headers + expect(getByText("Name")).toBeInTheDocument(); + expect(getByText("URL")).toBeInTheDocument(); + expect(getByText("Username")).toBeInTheDocument(); + + // Check pool data is displayed + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(getByText("stratum+tcp://mine.ocean.xyz:3334")).toBeInTheDocument(); + expect(getByText("ocean_user")).toBeInTheDocument(); + }); + + test("search is case insensitive", () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + const searchInput = getByTestId("pool-search-input"); + fireEvent.change(searchInput, { target: { value: "OCEAN" } }); + + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(queryByText("Braiins Pool")).not.toBeInTheDocument(); + }); + + test("clearing search shows all pools again", () => { + const { getByTestId, getByText } = render(); + + const searchInput = getByTestId("pool-search-input"); + + // First filter + fireEvent.change(searchInput, { target: { value: "ocean" } }); + expect(getByText("Ocean Pool")).toBeInTheDocument(); + + // Clear filter + fireEvent.change(searchInput, { target: { value: "" } }); + expect(getByText("Ocean Pool")).toBeInTheDocument(); + expect(getByText("Braiins Pool")).toBeInTheDocument(); + expect(getByText("Foundry USA")).toBeInTheDocument(); + }); + + test("shows success callout when test connection succeeds", async () => { + mockValidatePool.mockImplementation(({ onSuccess, onFinally }) => { + onSuccess?.(); + onFinally?.(); + }); + + const { getByText, getByTestId } = render(); + + // Select a pool + fireEvent.click(getByText("Ocean Pool")); + + // Click test connection + fireEvent.click(getByText("Test connection")); + + // Success callout should appear and be visible + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + }); + expect(getByText("Pool connection successful")).toBeInTheDocument(); + }); + + test("shows error callout when test connection fails", async () => { + mockValidatePool.mockImplementation(({ onError, onFinally }) => { + onError?.(); + onFinally?.(); + }); + + const { getByText, getByTestId } = render(); + + // Select a pool + fireEvent.click(getByText("Ocean Pool")); + + // Click test connection + fireEvent.click(getByText("Test connection")); + + // Error callout should appear and be visible + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-error-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + }); + expect( + getByText("We couldn't connect with your pool. Review your pool details and try again."), + ).toBeInTheDocument(); + }); + + test("dismisses callout when selecting a different pool", async () => { + mockValidatePool.mockImplementation(({ onSuccess, onFinally }) => { + onSuccess?.(); + onFinally?.(); + }); + + const { getByText, getByTestId } = render(); + + // Select a pool and test connection + fireEvent.click(getByText("Ocean Pool")); + fireEvent.click(getByText("Test connection")); + + // Wait for success callout to appear + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + }); + + // Select a different pool + fireEvent.click(getByText("Braiins Pool")); + + // Callout should be hidden + await waitFor(() => { + const callout = getByTestId("pool-selection-modal-connection-success-callout"); + expect(callout).toHaveClass("max-h-0"); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.tsx new file mode 100644 index 000000000..011d08754 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/PoolSelectionModal.tsx @@ -0,0 +1,346 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { create } from "@bufbuild/protobuf"; +import { MiningPool } from "../types"; +import { CreatePoolRequestSchema } from "@/protoFleet/api/generated/pools/v1/pools_pb"; +import usePools from "@/protoFleet/api/usePools"; +import { Alert, Success } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import { DismissibleCalloutWrapper, intents } from "@/shared/components/Callout"; +import Input from "@/shared/components/Input"; +import { emptyPoolInfo } from "@/shared/components/MiningPools/constants"; +import { fleetUsernameHelperText } from "@/shared/components/MiningPools/PoolForm/constants"; +import PoolModal from "@/shared/components/MiningPools/PoolModal"; +import { PoolInfo } from "@/shared/components/MiningPools/types"; +import Modal from "@/shared/components/Modal"; +import Radio from "@/shared/components/Radio"; + +const filterPoolsByQuery = (pools: MiningPool[], query: string): MiningPool[] => { + const lowerQuery = query.toLowerCase(); + return pools.filter( + (pool) => + pool.name.toLowerCase().includes(lowerQuery) || + pool.poolUrl.toLowerCase().includes(lowerQuery) || + pool.username.toLowerCase().includes(lowerQuery), + ); +}; + +interface PoolSelectableRowProps { + pool: MiningPool; + isSelected: boolean; + isDisabled: boolean; + onSelect?: () => void; + testId: string; +} + +const PoolSelectableRow = ({ pool, isSelected, isDisabled, onSelect, testId }: PoolSelectableRowProps) => ( +
!isDisabled && onSelect?.()} + data-testid={testId} + aria-disabled={isDisabled} + > +
+ +
+
+ {pool.name} +
+
+ {pool.poolUrl} +
+
+ {pool.username} +
+
+); + +interface PoolSelectionModalProps { + open?: boolean; + onDismiss: () => void; + onSave: (selectedPoolId: string, poolData?: MiningPool) => void; + excludedPoolIds?: (string | undefined)[]; + unknownPools?: MiningPool[]; +} + +const PoolSelectionModal = ({ + open, + onDismiss, + onSave, + excludedPoolIds = [], + unknownPools = [], +}: PoolSelectionModalProps) => { + const isVisible = open ?? true; + const [selectedPoolId, setSelectedPoolId] = useState(); + const [searchQuery, setSearchQuery] = useState(""); + const [showAddPoolModal, setShowAddPoolModal] = useState(false); + const [newPoolInfo, setNewPoolInfo] = useState([emptyPoolInfo]); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [showConnectionCallout, setShowConnectionCallout] = useState(false); + const [connectionError, setConnectionError] = useState(false); + + const { validatePool, createPool, miningPools } = usePools(isVisible); + + // Reset internal state when hidden to mirror prior conditional-mount behavior. + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (isVisible) { + return; + } + + setSelectedPoolId(undefined); + setSearchQuery(""); + setShowAddPoolModal(false); + setNewPoolInfo([emptyPoolInfo]); + setIsTestingConnection(false); + setShowConnectionCallout(false); + setConnectionError(false); + }, [isVisible]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const showSuccessCallout = useMemo( + () => showConnectionCallout && !isTestingConnection && !connectionError, + [showConnectionCallout, isTestingConnection, connectionError], + ); + + const showErrorCallout = useMemo( + () => showConnectionCallout && !isTestingConnection && connectionError, + [showConnectionCallout, isTestingConnection, connectionError], + ); + + const filteredPools = useMemo(() => filterPoolsByQuery(miningPools, searchQuery), [miningPools, searchQuery]); + + const filteredUnknownPools = useMemo( + () => filterPoolsByQuery(unknownPools, searchQuery), + [unknownPools, searchQuery], + ); + + const isPoolExcluded = (poolId: string) => excludedPoolIds.includes(poolId); + + const handleSave = () => { + if (selectedPoolId) { + onSave(selectedPoolId); + } + }; + + const handleTestSelectedConnection = useCallback(() => { + if (!selectedPoolId) return; + + const selectedPool = miningPools.find((p) => p.poolId === selectedPoolId); + if (!selectedPool) return; + + setIsTestingConnection(true); + setConnectionError(false); + validatePool({ + poolInfo: { + url: selectedPool.poolUrl, + username: selectedPool.username, + }, + onSuccess: () => { + setConnectionError(false); + }, + onError: () => { + setConnectionError(true); + }, + onFinally: () => { + setIsTestingConnection(false); + setShowConnectionCallout(true); + }, + }); + }, [selectedPoolId, miningPools, validatePool]); + + const handleNewPoolSave = async (pool: PoolInfo, isPasswordSet: boolean) => { + const createPoolRequest = create(CreatePoolRequestSchema, { + poolConfig: { + poolName: pool.name || "", + url: pool.url || "", + username: pool.username || "", + password: isPasswordSet && pool.password ? pool.password : "", + }, + }); + + return new Promise((resolve, reject) => { + createPool({ + createPoolRequest, + onSuccess: (poolId) => { + setShowAddPoolModal(false); + + const newPoolData: MiningPool = { + poolId: poolId, + name: pool.name || "", + poolUrl: pool.url || "", + username: pool.username || "", + }; + + onSave(poolId, newPoolData); + resolve(); + }, + onError: (error) => { + reject(new Error(error)); + }, + }); + }); + }; + + const handlePoolModalDismiss = () => { + setShowAddPoolModal(false); + setNewPoolInfo([emptyPoolInfo]); + }; + + const handleTestConnection = (args: { + poolInfo: PoolInfo; + onError?: (error?: string) => void; + onSuccess?: () => void; + onFinally?: () => void; + }) => { + setIsTestingConnection(true); + validatePool({ + poolInfo: { + url: args.poolInfo.url, + username: args.poolInfo.username, + password: args.poolInfo.password, + }, + onSuccess: () => { + args.onSuccess?.(); + }, + onError: (error) => { + args.onError?.(error); + }, + onFinally: () => { + setIsTestingConnection(false); + args.onFinally?.(); + }, + }); + }; + + if (showAddPoolModal) { + return ( + + ); + } + + return ( + +
+ } + intent={intents.success} + onDismiss={() => setShowConnectionCallout(false)} + show={showSuccessCallout} + title="Pool connection successful" + testId="pool-selection-modal-connection-success-callout" + /> + } + intent={intents.danger} + onDismiss={() => setShowConnectionCallout(false)} + show={showErrorCallout} + title="We couldn't connect with your pool. Review your pool details and try again." + testId="pool-selection-modal-connection-error-callout" + /> +
+ setSearchQuery(value)} + dismiss + testId="pool-search-input" + className="h-12" + autoFocus + /> +
+ + {/* Add new pool button */} +
+
+ +
+
+
+
Name
+
URL
+
Username
+
+ +
+ {filteredPools.length === 0 && filteredUnknownPools.length === 0 && searchQuery ? ( +
No pools found
+ ) : ( + <> + {filteredPools.map((pool) => ( + { + setSelectedPoolId(pool.poolId); + setShowConnectionCallout(false); + }} + testId={`pool-row-${pool.name}`} + /> + ))} + {filteredUnknownPools.map((pool) => ( + + ))} + + )} +
+
+
+
+ ); +}; + +export default PoolSelectionModal; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/index.ts b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/index.ts new file mode 100644 index 000000000..19c8cfe92 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionModal/index.ts @@ -0,0 +1 @@ +export { default } from "./PoolSelectionModal"; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.stories.tsx new file mode 100644 index 000000000..3d2facd2a --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.stories.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react"; +import PoolSelectionPageComponent from "./PoolSelectionPage"; +import { MockedPoolApis } from "@/protoFleet/stories/MockedPoolApis"; + +const withMockedPoolApis = (Story: () => ReactNode) => ( + + + +); + +interface PoolSelectionPageArgs { + numberOfMiners: number; +} + +export const PoolSelectionPage = ({ numberOfMiners }: PoolSelectionPageArgs) => { + const deviceIdentifiers = Array.from({ length: numberOfMiners }, (_, i) => `device-${i}`); + + return ( + {}} + onDismiss={() => {}} + /> + ); +}; + +export default { + title: "Proto Fleet/Action Bar/Settings widget/Pool selection page", + decorators: [withMockedPoolApis], + args: { + numberOfMiners: 1, + }, + argTypes: { + numberOfMiners: { + control: { type: "range", min: 1, max: 25, step: 1 }, + }, + }, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.test.tsx new file mode 100644 index 000000000..2eec7a528 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.test.tsx @@ -0,0 +1,518 @@ +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import PoolSelectionPage from "./PoolSelectionPage"; +import { PoolSchema } from "@/protoFleet/api/generated/pools/v1/pools_pb"; + +const mockPools = [ + create(PoolSchema, { + poolId: BigInt(1), + poolName: "Client pool A1", + url: "stratum+tcp://mine.ocean.xyz:3323", + username: "user1", + }), + create(PoolSchema, { + poolId: BigInt(2), + poolName: "Client pool A2", + url: "stratum+tcp://mine.ocean.xyz:3324", + username: "user2", + }), + create(PoolSchema, { + poolId: BigInt(3), + poolName: "Client pool A3", + url: "stratum+tcp://mine.ocean.xyz:3325", + username: "user3", + }), +]; + +const mockValidatePool = vi.fn(({ onSuccess, onFinally }) => { + onSuccess?.(); + onFinally?.(); +}); +const mockFetchPoolAssignments = vi.fn().mockResolvedValue([]); + +vi.mock("@/protoFleet/api/usePools", () => ({ + default: () => ({ + pools: mockPools, + miningPools: mockPools.map((pool) => ({ + poolId: pool.poolId.toString(), + name: pool.poolName, + poolUrl: pool.url, + username: pool.username, + })), + validatePool: mockValidatePool, + createPool: vi.fn(), + updatePool: vi.fn(), + deletePool: vi.fn(), + validatePoolPending: false, + }), +})); + +vi.mock("@/protoFleet/api/useMinerPoolAssignments", () => ({ + default: () => ({ + fetchPoolAssignments: mockFetchPoolAssignments, + isLoading: false, + }), +})); + +describe("Pool selection page", () => { + const numberOfMiners = 5; + const deviceIdentifiers = Array.from({ length: numberOfMiners }, (_, i) => `device-${i}`); + + const onCancel = vi.fn(); + const onAssignPools = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders page with Add pool button when no pools configured", () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText("Assign pools")).toBeInTheDocument(); + expect(getByTestId("add-pool-button")).toBeInTheDocument(); + expect(getByText("Add pool")).toBeInTheDocument(); + }); + + test("renders correct number of miners in button text", () => { + const { getByText } = render( + , + ); + + expect(getByText(`Assign to ${numberOfMiners} miners`)).toBeInTheDocument(); + }); + + test("uses numberOfMiners override when provided (Select All scenario)", () => { + // Simulates the "Select All" scenario where: + // - deviceIdentifiers contains only 50 visible miners from pagination + // - numberOfMiners is the actual total count (e.g., 297) + const visibleDeviceIdentifiers = Array.from({ length: 50 }, (_, i) => `device-${i}`); + const totalMinerCount = 297; + + const { getByText } = render( + , + ); + + // Should show the override count (297), not the deviceIdentifiers length (50) + expect(getByText(`Assign to ${totalMinerCount} miners`)).toBeInTheDocument(); + }); + + test("disables assign button when no pools are configured", async () => { + const { getByText } = render( + , + ); + + const assignButton = getByText(`Assign to ${numberOfMiners} miners`).closest("button"); + expect(assignButton).toBeDisabled(); + }); + + test("calls onCancel when close button clicked", async () => { + const { getAllByTestId } = render( + , + ); + + const closeModalButton = getAllByTestId("header-icon-button")[0]; + fireEvent.click(closeModalButton); + await waitFor(() => { + expect(onCancel).toHaveBeenCalled(); + }); + }); + + test("does not handle Escape when page is hidden", () => { + render( + , + ); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onCancel).not.toHaveBeenCalled(); + }); + + test("loads assignments only after page becomes visible", async () => { + const singleDevice = ["device-1"]; + + const { rerender } = render( + , + ); + + expect(mockFetchPoolAssignments).not.toHaveBeenCalled(); + + rerender( + , + ); + + await waitFor(() => { + expect(mockFetchPoolAssignments).toHaveBeenCalledWith("device-1"); + }); + }); + + test("opens selection modal when Add pool button is clicked", async () => { + const { getByText, getByTestId } = render( + , + ); + + const addPoolButton = getByTestId("add-pool-button"); + fireEvent.click(addPoolButton); + + await waitFor(() => { + expect(getByText("Select pool")).toBeInTheDocument(); + }); + }); + + test("adds pool to list when selected from modal", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Click Add pool button + fireEvent.click(getByTestId("add-pool-button")); + + await waitFor(() => { + expect(getByText("Select pool")).toBeInTheDocument(); + }); + + // Select a pool from the modal + fireEvent.click(getByText("Client pool A1")); + + // Click Save button + const saveButton = getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement; + fireEvent.click(saveButton); + + // Pool should be added to the list + await waitFor(() => { + expect(getByTestId("pool-row-0")).toBeInTheDocument(); + }); + + // Should show "Add another pool" button since we can add more + expect(getByTestId("add-another-pool-button")).toBeInTheDocument(); + }); + + test("shows Update button for each configured pool", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + + await waitFor(() => { + expect(getByTestId("pool-row-0")).toBeInTheDocument(); + }); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + + await waitFor(() => { + expect(getByTestId("pool-row-1")).toBeInTheDocument(); + }); + + // Both pools should have Update buttons + expect(getAllByText("Update").length).toBe(2); + }); + + test("enables assign button after adding a pool", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Assign button should be disabled initially + const assignButton = getByText(`Assign to ${numberOfMiners} miners`).closest("button"); + expect(assignButton).toBeDisabled(); + + // Add a pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + + // Wait for pool to be added + await waitFor(() => { + expect(getByTestId("pool-row-0")).toBeInTheDocument(); + }); + + // Assign button should be enabled now + expect(assignButton).not.toBeDisabled(); + }); + + test("hides Add another pool button when 3 pools are configured", async () => { + const { getByText, getByTestId, getAllByText, queryByTestId } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-1")).toBeInTheDocument()); + + // Add third pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A3")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-2")).toBeInTheDocument()); + + // "Add another pool" button should not be visible + expect(queryByTestId("add-another-pool-button")).not.toBeInTheDocument(); + }); + + test("calls onAssignPools with correct pool IDs when assign button clicked", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-1")).toBeInTheDocument()); + + // Click assign button + const assignButton = getByText(`Assign to ${numberOfMiners} miners`).closest("button") as HTMLElement; + fireEvent.click(assignButton); + + await waitFor(() => { + expect(onAssignPools).toHaveBeenCalledWith({ + defaultPool: { type: "poolId", poolId: "1" }, + backup1Pool: { type: "poolId", poolId: "2" }, + backup2Pool: undefined, + }); + }); + }); + + test("shows priority numbers in pool list", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add first pool + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Add second pool + fireEvent.click(getByTestId("add-another-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A2")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-1")).toBeInTheDocument()); + + // Check priority numbers are displayed + const poolRow0 = getByTestId("pool-row-0"); + const poolRow1 = getByTestId("pool-row-1"); + + expect(poolRow0).toHaveTextContent("1"); + expect(poolRow1).toHaveTextContent("2"); + }); + + test("shows Add new pool button in selection modal", async () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.click(getByTestId("add-pool-button")); + + await waitFor(() => { + expect(getByText("Select pool")).toBeInTheDocument(); + }); + + expect(getByTestId("add-new-pool-button")).toBeInTheDocument(); + expect(getByText("Add new pool")).toBeInTheDocument(); + }); + + test("shows success callout when test connection succeeds", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Success callout should appear and be visible (max-h-96) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + expect(getByText("Pool connection successful")).toBeInTheDocument(); + }); + }); + + test("dismisses success callout when dismiss button is clicked", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Success callout should appear with max-h-96 (visible state) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + }); + + // Find and click the dismiss button within the callout + const callout = getByTestId("pool-selection-page-connection-success-callout"); + const dismissButton = callout.querySelector("button"); + if (dismissButton) { + fireEvent.click(dismissButton); + } + + // Callout should be hidden (max-h-0 class) + await waitFor(() => { + const calloutAfter = getByTestId("pool-selection-page-connection-success-callout"); + expect(calloutAfter).toHaveClass("max-h-0"); + }); + }); + + test("shows error callout when test connection fails", async () => { + // Override mock to simulate failure + mockValidatePool.mockImplementationOnce(({ onError, onFinally }) => { + onError?.(); + onFinally?.(); + }); + + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Error callout should appear and be visible (max-h-96) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-error-callout"); + expect(callout).toHaveClass("max-h-96"); + expect(callout).not.toHaveClass("max-h-0"); + expect( + getByText("We couldn't connect with your pool. Review your pool details and try again."), + ).toBeInTheDocument(); + }); + }); + + test("dismisses callout when opening pool selection modal", async () => { + const { getByText, getByTestId, getAllByText } = render( + , + ); + + // Add a pool first + fireEvent.click(getByTestId("add-pool-button")); + await waitFor(() => expect(getByText("Select pool")).toBeInTheDocument()); + fireEvent.click(getByText("Client pool A1")); + fireEvent.click(getAllByText("Save").find((btn) => btn.closest("button")) as HTMLElement); + await waitFor(() => expect(getByTestId("pool-row-0")).toBeInTheDocument()); + + // Click test connection via the actions menu + const actionsButton = getByTestId("pool-1-actions-menu-button"); + fireEvent.click(actionsButton); + + await waitFor(() => { + expect(getByTestId("pool-1-test-connection-action")).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId("pool-1-test-connection-action")); + + // Success callout should appear with max-h-96 (visible state) + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-96"); + }); + + // Open pool selection modal (Add another pool) + fireEvent.click(getByTestId("add-another-pool-button")); + + // Callout should be hidden (max-h-0 class) when modal opens + await waitFor(() => { + const callout = getByTestId("pool-selection-page-connection-success-callout"); + expect(callout).toHaveClass("max-h-0"); + }); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.tsx b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.tsx new file mode 100644 index 000000000..06c2521f2 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/ActionBar/SettingsWidget/PoolSelectionPage/PoolSelectionPage.tsx @@ -0,0 +1,495 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import FleetPoolRow from "./FleetPoolRow"; +import PoolSelectionModal from "./PoolSelectionModal/PoolSelectionModal"; +import { MiningPool } from "./types"; +import { PoolConfig, PoolSlotSource } from "@/protoFleet/api/useMinerCommand"; +import useMinerPoolAssignments from "@/protoFleet/api/useMinerPoolAssignments"; +import usePools from "@/protoFleet/api/usePools"; +import { Alert, DismissCircleDark, Success } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Callout, { DismissibleCalloutWrapper, intents } from "@/shared/components/Callout"; +import Header from "@/shared/components/Header"; +import { MAX_POOLS } from "@/shared/components/MiningPools/constants"; +import PageOverlay from "@/shared/components/PageOverlay"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +const UNKNOWN_POOL_ID_PREFIX = "unknown-"; + +interface AssignedPoolData { + poolId: string | undefined; // undefined when pool not in Fleet + poolName: string; // Stored locally to avoid race conditions with miningPools lookup + poolUrl: string; + poolUsername: string; +} + +interface PoolSelectionPageProps { + open?: boolean; + deviceIdentifiers: string[]; + numberOfMiners?: number; // Optional explicit count (for "all" mode with filters) + currentDevice?: string | null; // Optional single device identifier (for single miner edit) + onAssignPools: (poolConfig: PoolConfig) => Promise; + onDismiss: () => void; +} + +const PoolSelectionPage = ({ + open, + deviceIdentifiers, + numberOfMiners: numberOfMinersOverride, + currentDevice, + onAssignPools, + onDismiss: onCancel, +}: PoolSelectionPageProps) => { + const isVisible = open ?? true; + const [assignedPoolData, setAssignedPoolData] = useState([]); + const [showSelectionModal, setShowSelectionModal] = useState(false); + const [editingPoolIndex, setEditingPoolIndex] = useState(null); + const [testingPoolId, setTestingPoolId] = useState(null); + const [showConnectionCallout, setShowConnectionCallout] = useState(false); + const [connectionError, setConnectionError] = useState(false); + + const showSuccessCallout = useMemo( + () => showConnectionCallout && !testingPoolId && !connectionError, + [showConnectionCallout, testingPoolId, connectionError], + ); + + const showErrorCallout = useMemo( + () => showConnectionCallout && !testingPoolId && connectionError, + [showConnectionCallout, testingPoolId, connectionError], + ); + + const { fetchPoolAssignments, isLoading: isLoadingAssignments } = useMinerPoolAssignments(); + const { miningPools, validatePool } = usePools(isVisible); + const [isAssigning, setIsAssigning] = useState(false); + + const loadedDeviceRef = useRef(null); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // Handle ESC key to dismiss the page (only when modal is not open) + useEffect(() => { + if (!isVisible) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !showSelectionModal) { + onCancel(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isVisible, onCancel, showSelectionModal]); + + // Reset internal state when hidden to mirror prior conditional-mount behavior. + useEffect(() => { + if (isVisible) { + return; + } + + loadedDeviceRef.current = null; + setAssignedPoolData([]); + setShowSelectionModal(false); + setEditingPoolIndex(null); + setTestingPoolId(null); + setShowConnectionCallout(false); + setConnectionError(false); + }, [isVisible]); + + useEffect(() => { + if (!isVisible) { + return; + } + + const deviceToLoad = currentDevice ?? (deviceIdentifiers.length === 1 ? deviceIdentifiers[0] : null); + + if (loadedDeviceRef.current === deviceToLoad) { + return; + } + + const isDeviceChange = loadedDeviceRef.current !== null; + let isMounted = true; + + const loadExistingPoolAssignments = async () => { + if (isDeviceChange) { + setAssignedPoolData([]); + } + + if (!deviceToLoad) { + loadedDeviceRef.current = deviceToLoad; + return; + } + + const pools = await fetchPoolAssignments(deviceToLoad); + if (!isMounted) return; + + const poolData: AssignedPoolData[] = pools.map((pool) => ({ + poolId: pool.poolId?.toString(), + poolName: "", + poolUrl: pool.url, + poolUsername: pool.username, + })); + setAssignedPoolData(poolData); + loadedDeviceRef.current = deviceToLoad; + }; + + loadExistingPoolAssignments(); + + return () => { + isMounted = false; + }; + }, [isVisible, deviceIdentifiers, currentDevice, fetchPoolAssignments]); + + // Create a stable ID for each pool (either real poolId or synthetic for unknown pools) + const getPoolDisplayId = useCallback((data: AssignedPoolData, index: number): string => { + return data.poolId ?? `${UNKNOWN_POOL_ID_PREFIX}${index}`; + }, []); + + // IDs for drag-and-drop context + const sortableIds = useMemo( + () => assignedPoolData.map((data, index) => getPoolDisplayId(data, index)), + [assignedPoolData, getPoolDisplayId], + ); + + // Map assigned pool data to MiningPool objects for display + const assignedPools = useMemo( + () => + assignedPoolData.map((data, index): MiningPool => { + // Use stored pool name if available (for newly created pools). + // Otherwise look up from miningPools (for pools loaded from API). + let name = data.poolName; + if (!name && data.poolId) { + const knownPool = miningPools.find((p) => p.poolId === data.poolId); + if (knownPool) { + name = knownPool.name; + } + } + + return { + poolId: getPoolDisplayId(data, index), + name: name || data.poolUrl, + poolUrl: data.poolUrl, + username: data.poolUsername, + }; + }), + [assignedPoolData, miningPools, getPoolDisplayId], + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setAssignedPoolData((items) => { + const oldIndex = sortableIds.indexOf(active.id as string); + const newIndex = sortableIds.indexOf(over.id as string); + return arrayMove(items, oldIndex, newIndex); + }); + } + }, + [sortableIds], + ); + + const handleAddPool = useCallback(() => { + setEditingPoolIndex(null); + setShowSelectionModal(true); + setShowConnectionCallout(false); + }, []); + + const handleUpdatePool = useCallback((index: number) => { + setEditingPoolIndex(index); + setShowSelectionModal(true); + setShowConnectionCallout(false); + }, []); + + const handlePoolSelected = useCallback( + (poolId: string, poolData?: MiningPool) => { + // Use provided poolData (for newly created pools) or find from miningPools + const selectedPool = poolData ?? miningPools.find((p) => p.poolId === poolId); + if (!selectedPool) return; + + const newPoolData: AssignedPoolData = { + poolId: poolId, + poolName: selectedPool.name, + poolUrl: selectedPool.poolUrl, + poolUsername: selectedPool.username, + }; + + if (editingPoolIndex !== null) { + setAssignedPoolData((prev) => { + const newData = [...prev]; + newData[editingPoolIndex] = newPoolData; + return newData; + }); + } else { + setAssignedPoolData((prev) => [...prev, newPoolData]); + } + setShowSelectionModal(false); + setEditingPoolIndex(null); + setShowConnectionCallout(false); + }, + [editingPoolIndex, miningPools], + ); + + const handleRemovePool = useCallback( + (displayId: string) => { + const indexToRemove = sortableIds.indexOf(displayId); + if (indexToRemove !== -1) { + setAssignedPoolData((prev) => prev.filter((_, index) => index !== indexToRemove)); + } + }, + [sortableIds], + ); + + const handleTestConnection = useCallback( + (pool: MiningPool) => { + if (testingPoolId) return; + + setTestingPoolId(pool.poolId); + setConnectionError(false); + validatePool({ + poolInfo: { + url: pool.poolUrl, + username: pool.username, + }, + onSuccess: () => { + setConnectionError(false); + }, + onError: () => { + setConnectionError(true); + }, + onFinally: () => { + setTestingPoolId(null); + setShowConnectionCallout(true); + }, + }); + }, + [testingPoolId, validatePool], + ); + + const handleAssignPoolsClick = async () => { + if (assignedPoolData.length === 0) return; + + setIsAssigning(true); + try { + // Convert assigned pool data to PoolSlotSource objects + const toPoolSlotSource = (data: AssignedPoolData): PoolSlotSource => { + if (data.poolId) { + return { type: "poolId", poolId: data.poolId }; + } else { + return { type: "rawPool", url: data.poolUrl, username: data.poolUsername }; + } + }; + + const poolConfig: PoolConfig = { + defaultPool: toPoolSlotSource(assignedPoolData[0]), + backup1Pool: assignedPoolData[1] ? toPoolSlotSource(assignedPoolData[1]) : undefined, + backup2Pool: assignedPoolData[2] ? toPoolSlotSource(assignedPoolData[2]) : undefined, + }; + + await onAssignPools(poolConfig); + } catch (error) { + console.error("Failed to assign pools:", error); + } finally { + setIsAssigning(false); + } + }; + + const numberOfMiners = numberOfMinersOverride ?? deviceIdentifiers.length; + const buttonText = `Assign to ${numberOfMiners} miner${numberOfMiners === 1 ? "" : "s"}`; + const isSingleMinerEdit = numberOfMiners === 1; + const isLoadingInitialState = isSingleMinerEdit && isLoadingAssignments; + const hasConfiguredPools = assignedPoolData.length > 0; + const canAddMorePools = assignedPoolData.length < MAX_POOLS; + + // Extract known pool IDs for modal exclusion (all assigned pools should be greyed out) + const excludedPoolIds = assignedPoolData.map((data) => data.poolId).filter((id): id is string => id !== undefined); + + // Extract unknown pools (pools on miner but not in Fleet) for display in modal + // Always show all unknown pools, even when editing one (they're disabled anyway) + // Use consistent IDs that match getPoolDisplayId to avoid mismatches + const unknownPoolsForModal = useMemo( + () => + assignedPoolData + .map((data, index) => ({ data, originalIndex: index })) + .filter(({ data }) => data.poolId === undefined) + .map(({ data, originalIndex }) => ({ + poolId: getPoolDisplayId(data, originalIndex), + name: "—", + poolUrl: data.poolUrl, + username: data.poolUsername, + })), + [assignedPoolData, getPoolDisplayId], + ); + + // Check for duplicate URL+username combinations in assigned pools + const hasDuplicatePools = useMemo(() => { + if (assignedPoolData.length < 2) return false; + + const seen = new Set(); + for (const pool of assignedPoolData) { + const key = `${pool.poolUrl.trim().toLowerCase()}|${pool.poolUsername.trim().toLowerCase()}`; + if (seen.has(key)) { + return true; + } + seen.add(key); + } + return false; + }, [assignedPoolData]); + + return ( + +
+
+ } + inline + buttons={[ + { + text: buttonText, + variant: variants.primary, + onClick: handleAssignPoolsClick, + disabled: !hasConfiguredPools || isLoadingInitialState || isAssigning || hasDuplicatePools, + loading: isAssigning, + }, + ]} + /> + +
+
+ {/* Page header */} +
+

Assign pools to your miner

+

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

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

{title}

+
+ {selectedPool ? ( +

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

+ ) : subtitle ? ( +

{subtitle}

+ ) : null} + {displayError ?

{displayError}

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

Some miners do not support this action.

+

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

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

{propertiesTitle}

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

{separatorTitle}

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

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

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

Update the admin login for your miners

+

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

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

{title}

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

{title}

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

{title}

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

{title}

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

{title}

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

{title}

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

{title}

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

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

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

Manage columns

+

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

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

{title}

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

All Statuses and Issues

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

Group not found

+

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

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

Error loading group

+

{resolveError}

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

{error}

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

Groups

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

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

+

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

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