diff --git a/client/e2eTests/protoFleet/helpers/buildingsTestSetup.ts b/client/e2eTests/protoFleet/helpers/buildingsTestSetup.ts index a3d6619fb1..549a9345b8 100644 --- a/client/e2eTests/protoFleet/helpers/buildingsTestSetup.ts +++ b/client/e2eTests/protoFleet/helpers/buildingsTestSetup.ts @@ -144,13 +144,13 @@ export async function createRackWithAssignedMiners( await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(RACK_COLUMNS); await racksPage.inputRows(RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); const selectedMinerIps = (await addSelectableMinersToSlots(racksPage, 2, [1, 2])).map((miner) => miner.ipAddress); test.expect(selectedMinerIps).toHaveLength(2); - await racksPage.clickSaveRack(); - await racksPage.validateRackToast(rackLabel); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast(rackLabel); await racksPage.clickViewList(); await racksPage.waitForRackListToLoad({ allowEmpty: false }); diff --git a/client/e2eTests/protoFleet/helpers/racksHelpers.ts b/client/e2eTests/protoFleet/helpers/racksHelpers.ts index d3ca1e293d..2e3b8b4e53 100644 --- a/client/e2eTests/protoFleet/helpers/racksHelpers.ts +++ b/client/e2eTests/protoFleet/helpers/racksHelpers.ts @@ -23,7 +23,7 @@ export async function addSelectableMinersToSlots( const selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(minerCount); const selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); await racksPage.selectMinersInSelectorByIndex(selectableMinerIndexes); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); for (let i = 0; i < selectedMiners.length; i++) { await racksPage.selectRackMiner(selectedMiners[i].ipAddress); @@ -48,7 +48,7 @@ export async function addSelectableRigMinersToSlots( const selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(minerCount); const selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); await racksPage.selectMinersInSelectorByIndex(selectableMinerIndexes); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); for (let i = 0; i < selectedMiners.length; i++) { await racksPage.selectRackMiner(selectedMiners[i].ipAddress); diff --git a/client/e2eTests/protoFleet/helpers/rbacTestSetup.ts b/client/e2eTests/protoFleet/helpers/rbacTestSetup.ts index 17c27ac5c8..141769ca3b 100644 --- a/client/e2eTests/protoFleet/helpers/rbacTestSetup.ts +++ b/client/e2eTests/protoFleet/helpers/rbacTestSetup.ts @@ -287,9 +287,9 @@ export async function createRack(racksPage: RacksPage, rackLabel: string) { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(2); await racksPage.inputRows(2); - await racksPage.clickContinueFromRackSettings(); - await racksPage.clickSaveRack(); + await racksPage.clickCreateRackFromSettings(); await racksPage.validateRackToast(rackLabel); + await racksPage.clickDismissManageRack(); await racksPage.clickViewList(); await racksPage.waitForRackListToLoad({ allowEmpty: false }); await racksPage.validateRackRow(rackLabel, RBAC_RACK_ZONE, 0); diff --git a/client/e2eTests/protoFleet/pages/racks.ts b/client/e2eTests/protoFleet/pages/racks.ts index 9659b0e417..353d0dabb7 100644 --- a/client/e2eTests/protoFleet/pages/racks.ts +++ b/client/e2eTests/protoFleet/pages/racks.ts @@ -59,8 +59,15 @@ export class RacksPage extends BasePage { .trim(); } - async clickContinueFromRackSettings() { - await this.clickIn("Continue", "modal"); + // The Rack settings CTA creates the rack, so a new rack reads "Create rack" + // and lands on the manage-rack modal with the rack already persisted. + async clickCreateRackFromSettings() { + await this.clickIn("Create rack", "modal"); + } + + // Reopened on an existing rack, the same CTA updates the rack's own fields. + async clickSaveRackSettings() { + await this.clickIn("Save", "modal"); } async validateRackSettingsFieldError( @@ -176,8 +183,9 @@ export class RacksPage extends BasePage { await this.modalMinerList.selectRowByCellText("ipAddress", ipAddress); } - async clickContinueInMinerSelector() { - await this.clickIn("Continue", "modal"); + // The miner picker commits the rack's membership itself, hence "Save". + async clickSaveInMinerSelector() { + await this.clickIn("Save", "modal"); } async validateMinerSelectorOverflowError(selectedCount: number, maxSlots: number) { @@ -303,10 +311,20 @@ export class RacksPage extends BasePage { await this.page.getByRole("button", { name: "Clear", exact: true }).click(); } - async clickSaveRack() { + // The manage-rack modal's Save persists slot placement only, and is disabled + // until a miner's slot actually changes. + async clickSaveMinerPositions() { await this.clickButton("Save"); } + async clickDismissManageRack() { + await this.page.getByRole("button", { name: "Close dialog", exact: true }).click(); + } + + async validateSaveMinerPositionsDisabled() { + await expect(this.page.getByRole("button", { name: "Save", exact: true })).toBeDisabled(); + } + async clickViewMiners() { const directButton = this.page.getByTestId("rack-page-view-miners"); if (await directButton.isVisible().catch(() => false)) { @@ -331,15 +349,21 @@ export class RacksPage extends BasePage { await this.validateTitleInModal("Rack settings"); } - async changeOrderIndexAndContinue(orderIndexLabel: string) { + // Reached through Edit Rack Settings, so the rack already exists and the CTA + // persists the change straight away. + async changeOrderIndexAndSaveSettings(orderIndexLabel: string) { await this.selectOption("order-index-select", orderIndexLabel); - await this.clickContinueFromRackSettings(); + await this.clickSaveRackSettings(); } - async validateRackToast(label: string, action: "created" | "updated" = "created") { + async validateRackToast(label: string, action: "created" | "saved" = "created") { await this.validateTextInToast(`Rack "${label}" ${action}`); } + async validateMinerPositionsToast(label: string) { + await this.validateTextInToast(`Miner positions saved for "${label}"`); + } + async validateRackCardVisible(label: string, zone: string) { await expect(this.getRackCard(label, zone)).toBeVisible(); } diff --git a/client/e2eTests/protoFleet/spec/racksCreation.spec.ts b/client/e2eTests/protoFleet/spec/racksCreation.spec.ts index d3cdde405d..0aefc0d975 100644 --- a/client/e2eTests/protoFleet/spec/racksCreation.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksCreation.spec.ts @@ -31,7 +31,7 @@ test.describe("Racks - creation", () => { await racksPage.inputRows(RACK_ROWS); orderIndexValue = await racksPage.getOrderIndexValue(); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); }); await test.step("Validate empty rack assignment state", async () => { @@ -46,7 +46,7 @@ test.describe("Racks - creation", () => { selectedMiners = await racksPage.getMinersFromSelector([0, 1]); test.expect(selectedMiners).toHaveLength(2); await racksPage.selectMinersInSelectorByIndex([0, 1]); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); }); await test.step("Assign miners by name and validate positions", async () => { @@ -55,8 +55,8 @@ test.describe("Racks - creation", () => { }); await test.step("Save rack and validate rack grid card", async () => { - await racksPage.clickSaveRack(); - await racksPage.validateRackToast(rackLabel); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast(rackLabel); await racksPage.clickViewGrid(); await racksPage.validateRackCardVisible(rackLabel, AUTOMATION_ZONE); await racksPage.validateRackCardGrid(rackLabel, AUTOMATION_ZONE, RACK_COLUMNS, RACK_ROWS); @@ -79,7 +79,7 @@ test.describe("Racks - creation", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(RACK_COLUMNS); await racksPage.inputRows(RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); }); await test.step("Add four miners", async () => { @@ -89,7 +89,7 @@ test.describe("Racks - creation", () => { selectedMiners = await racksPage.getMinersFromSelector([0, 1, 2, 3]); test.expect(selectedMiners).toHaveLength(4); await racksPage.selectMinersInSelectorByIndex([0, 1, 2, 3]); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); }); await test.step("Assign miners manually in DOM order and validate default numbering", async () => { @@ -103,7 +103,8 @@ test.describe("Racks - creation", () => { 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.changeOrderIndexAndSaveSettings(scenario.label); + await racksPage.validateRackToast(RACK_LABEL, "saved"); await racksPage.validateRackConfiguration(RACK_COLUMNS, RACK_ROWS, scenario.label); await racksPage.validateRackSlotNumbersInDomOrder(scenario.expectedNumbers); await racksPage.validateMinerPositions(selectedMiners, scenario.expectedNumbers); @@ -121,7 +122,7 @@ test.describe("Racks - creation", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(NETWORK_RACK_COLUMNS); await racksPage.inputRows(NETWORK_RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); await racksPage.clickAddMiners(); await racksPage.waitForMinerSelectorListToLoad(); @@ -129,7 +130,7 @@ test.describe("Racks - creation", () => { test.expect(allVisibleMiners.length).toBeGreaterThan(0); test.expect(allVisibleMiners.length).toBeLessThanOrEqual(NETWORK_RACK_COLUMNS * NETWORK_RACK_ROWS); await racksPage.clickSelectAllMinersInSelector(); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); }); await test.step("Assign all miners by network and validate positions by IP and name", async () => { diff --git a/client/e2eTests/protoFleet/spec/racksManagement.spec.ts b/client/e2eTests/protoFleet/spec/racksManagement.spec.ts index 522fad8050..929ad14f21 100644 --- a/client/e2eTests/protoFleet/spec/racksManagement.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksManagement.spec.ts @@ -29,10 +29,11 @@ test.describe("Racks - management", () => { 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.clickCreateRackFromSettings(); await racksPage.validateRackToast("A-01"); + await addSelectableMinersToSlots(racksPage, 3, [1, 2, 3]); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast("A-01"); await racksPage.clickViewGrid(); await racksPage.validateRackCardVisible("A-01", zoneA); createdRackLabels.push("A-01"); @@ -42,10 +43,11 @@ test.describe("Racks - management", () => { await racksPage.clickAddRackButton(); await racksPage.inputZone(zoneA); await racksPage.inputRackLabel("A-02"); - await racksPage.clickContinueFromRackSettings(); - await addSelectableMinersToSlots(racksPage, 2, [1, 2]); - await racksPage.clickSaveRack(); + await racksPage.clickCreateRackFromSettings(); await racksPage.validateRackToast("A-02"); + await addSelectableMinersToSlots(racksPage, 2, [1, 2]); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast("A-02"); await racksPage.clickViewGrid(); await racksPage.validateRackCardVisible("A-02", zoneA); createdRackLabels.push("A-02"); @@ -55,10 +57,11 @@ test.describe("Racks - management", () => { await racksPage.clickAddRackButton(); await racksPage.inputZone(zoneB); await racksPage.inputRackLabel("B-01"); - await racksPage.clickContinueFromRackSettings(); - await addSelectableMinersToSlots(racksPage, 1, [1]); - await racksPage.clickSaveRack(); + await racksPage.clickCreateRackFromSettings(); await racksPage.validateRackToast("B-01"); + await addSelectableMinersToSlots(racksPage, 1, [1]); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast("B-01"); await racksPage.clickViewGrid(); await racksPage.validateRackCardVisible("B-01", zoneB); createdRackLabels.push("B-01"); @@ -107,7 +110,8 @@ test.describe("Racks - management", () => { await test.step("Validate required label and invalid dimensions", async () => { // Zone is optional now; the label is required and empty by default, so - // continuing without typing one surfaces the label error. + // submitting without typing one surfaces the label error instead of + // creating the rack. await racksPage.clickAddRackButton(); await racksPage.inputZone(validationZone); generatedRackLabel = "A-01"; @@ -115,7 +119,7 @@ test.describe("Racks - management", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(0); await racksPage.inputRows(13); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); await racksPage.validateRackSettingsFieldError("rack-label", "A label is required"); await racksPage.validateRackSettingsFieldError("rack-columns", "Columns must be a whole number between 1 and 12"); @@ -123,11 +127,12 @@ test.describe("Racks - management", () => { await racksPage.validateTitleInModal("Rack settings"); }); - await test.step("Correct rack settings and continue", async () => { + await test.step("Correct rack settings and create the rack", async () => { await racksPage.inputRackLabel(generatedRackLabel); await racksPage.inputColumns(VALIDATION_RACK_COLUMNS); await racksPage.inputRows(VALIDATION_RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); + await racksPage.validateRackToast(generatedRackLabel); await racksPage.validateRackConfiguration(VALIDATION_RACK_COLUMNS, VALIDATION_RACK_ROWS, "Bottom left"); await racksPage.validateAssignedMinersCount(0, 1); @@ -140,18 +145,18 @@ test.describe("Racks - management", () => { const selectableMinerIndexes = await racksPage.getSelectableMinerIndexes(2); selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); await racksPage.selectMinersInSelectorByIndex(selectableMinerIndexes); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); await racksPage.validateMinerSelectorOverflowError(2, 1); await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[1].ipAddress); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); }); 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.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast(generatedRackLabel); await racksPage.validateRackCardVisible(generatedRackLabel, validationZone); await racksPage.validateRackCardGrid( generatedRackLabel, diff --git a/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts b/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts index 3b0578ef6a..0ba7db0554 100644 --- a/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksManualAssignment.spec.ts @@ -28,7 +28,7 @@ test.describe("Racks - manual assignment", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(LARGE_RACK_COLUMNS); await racksPage.inputRows(LARGE_RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); }); await test.step("Manage miners and add the first miner to the rack list", async () => { @@ -39,7 +39,7 @@ test.describe("Racks - manual assignment", () => { selectedMiners = await racksPage.getMinersFromSelector(selectableMinerIndexes); test.expect(selectedMiners).toHaveLength(2); await racksPage.selectMinersInSelectorByIndex([selectableMinerIndexes[0]]); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); }); await test.step("Search and assign the second miner to slot 04", async () => { @@ -102,8 +102,8 @@ test.describe("Racks - manual assignment", () => { await racksPage.validateMinerRowPosition(selectedMiners[1].ipAddress, 9); await racksPage.validateRackSlotsHighlighted([1, 9]); - await racksPage.clickSaveRack(); - await racksPage.validateRackToast(rackLabel); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast(rackLabel); }); await test.step("Open the created rack and validate saved slots", async () => { @@ -130,9 +130,10 @@ test.describe("Racks - manual assignment", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(OVERVIEW_RACK_COLUMNS); await racksPage.inputRows(OVERVIEW_RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); - await racksPage.clickSaveRack(); + await racksPage.clickCreateRackFromSettings(); await racksPage.validateRackToast(rackLabel); + // No miner has a slot yet, so there is no placement to save. + await racksPage.validateSaveMinerPositionsDisabled(); }); await test.step("Open the created rack and assign the first miner to slot 02", async () => { @@ -182,10 +183,10 @@ test.describe("Racks - manual assignment", () => { await racksPage.waitForMinerSelectorListToLoad(); await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[0].ipAddress); await racksPage.toggleMinerInSelectorByIpAddress(selectedMiners[1].ipAddress); - await racksPage.clickContinueInMinerSelector(); + await racksPage.clickSaveInMinerSelector(); await racksPage.validateTextIsVisible("No miners added to this rack yet."); - await racksPage.clickSaveRack(); - await racksPage.validateRackToast(rackLabel, "updated"); + await racksPage.clickSaveMinerPositions(); + await racksPage.validateMinerPositionsToast(rackLabel); }); await test.step("Validate rack overview is empty after saving", async () => { diff --git a/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts b/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts index bd0df1f3af..bcc91a5147 100644 --- a/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts +++ b/client/e2eTests/protoFleet/spec/racksOverviewActions.spec.ts @@ -25,7 +25,9 @@ test.describe("Racks - overview actions", () => { let rackDeviceIdentifiers: string[] = []; await test.step("Create and save a new rack with two rig miners", async () => { - const saveRackRequestPromise = page.waitForRequest(/SaveRack/); + // The miner picker commits membership itself, so the rack's members + // arrive on the first AssignDevicesToRack call, not on a SaveRack. + const assignRequestPromise = page.waitForRequest(/AssignDevicesToRack/); await racksPage.clickAddRackButton(); await racksPage.inputZone(AUTOMATION_ZONE); @@ -36,19 +38,18 @@ test.describe("Racks - overview actions", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(RACK_COLUMNS); await racksPage.inputRows(RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); selectedMiners = await addSelectableRigMinersToSlots(racksPage, 2, [1, 2]); test.expect(selectedMiners).toHaveLength(2); test.expect(selectedMiners.every((miner) => miner.model === PROTO_RIG_MODEL)).toBe(true); - await racksPage.clickSaveRack(); + await racksPage.clickSaveMinerPositions(); - const saveRackRequest = await saveRackRequestPromise; - const saveRackRequestBody = saveRackRequest.postDataJSON(); - rackDeviceIdentifiers = saveRackRequestBody.deviceSelector.deviceList.deviceIdentifiers; + const assignRequest = await assignRequestPromise; + rackDeviceIdentifiers = assignRequest.postDataJSON().deviceIdentifiers; - await racksPage.validateRackToast(rackLabel); + await racksPage.validateMinerPositionsToast(rackLabel); test.expect(rackDeviceIdentifiers).toHaveLength(2); }); @@ -109,7 +110,9 @@ test.describe("Racks - overview actions", () => { try { await test.step("Create a rack with two assigned Proto rigs", async () => { - const saveRackRequestPromise = page.waitForRequest(/SaveRack/); + // The miner picker commits membership itself, so the rack's members + // arrive on the first AssignDevicesToRack call, not on a SaveRack. + const assignRequestPromise = page.waitForRequest(/AssignDevicesToRack/); await racksPage.clickAddRackButton(); await racksPage.inputZone(AUTOMATION_ZONE); @@ -118,15 +121,14 @@ test.describe("Racks - overview actions", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(OVERVIEW_RACK_COLUMNS); await racksPage.inputRows(OVERVIEW_RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); await addSelectableRigMinersToSlots(racksPage, 2, [1, 2]); - await racksPage.clickSaveRack(); + await racksPage.clickSaveMinerPositions(); - const saveRackRequest = await saveRackRequestPromise; - const saveRackRequestBody = saveRackRequest.postDataJSON(); - rackDeviceIdentifiers = saveRackRequestBody.deviceSelector.deviceList.deviceIdentifiers; + const assignRequest = await assignRequestPromise; + rackDeviceIdentifiers = assignRequest.postDataJSON().deviceIdentifiers; - await racksPage.validateRackToast(rackLabel); + await racksPage.validateMinerPositionsToast(rackLabel); test.expect(rackDeviceIdentifiers).toHaveLength(2); }); @@ -191,11 +193,11 @@ test.describe("Racks - overview actions", () => { await racksPage.enableCustomRackLayout(); await racksPage.inputColumns(OVERVIEW_RACK_COLUMNS); await racksPage.inputRows(OVERVIEW_RACK_ROWS); - await racksPage.clickContinueFromRackSettings(); + await racksPage.clickCreateRackFromSettings(); await addSelectableRigMinersToSlots(racksPage, 2, [1, 2]); - await racksPage.clickSaveRack(); + await racksPage.clickSaveMinerPositions(); - await racksPage.validateRackToast(rackLabel); + await racksPage.validateMinerPositionsToast(rackLabel); await racksPage.clickViewGrid(); await racksPage.openRackCard(rackLabel, AUTOMATION_ZONE); await racksPage.openRackOverviewActionsMenu(); 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 index 77b4c66727..70a7cdf97a 100644 --- 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 @@ -27,7 +27,7 @@ import type { Message } from "@bufbuild/protobuf"; export const file_device_set_v1_device_set: GenFile = /*@__PURE__*/ fileDesc( - "Ch5kZXZpY2Vfc2V0L3YxL2RldmljZV9zZXQucHJvdG8SDWRldmljZV9zZXQudjEi+AIKCURldmljZVNldBIKCgJpZBgBIAEoAxIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlEg0KBWxhYmVsGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhQKDGRldmljZV9jb3VudBgFIAEoBRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIsCglyYWNrX2luZm8YCCABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgJIAEoCzIYLmRldmljZV9zZXQudjEuR3JvdXBJbmZvSAASKwoJcGxhY2VtZW50GAogASgLMhguY29tbW9uLnYxLlBsYWNlbWVudFJlZnNCDgoMdHlwZV9kZXRhaWxzIogCCghSYWNrSW5mbxIVCgRyb3dzGAEgASgFQge6SAQaAiAAEhgKB2NvbHVtbnMYAiABKAVCB7pIBBoCIAASFQoEem9uZRgDIAEoCUIHukgEcgIYZBIyCgtvcmRlcl9pbmRleBgEIAEoDjIdLmRldmljZV9zZXQudjEuUmFja09yZGVySW5kZXgSNAoMY29vbGluZ190eXBlGAUgASgOMh4uZGV2aWNlX3NldC52MS5SYWNrQ29vbGluZ1R5cGUSFAoHc2l0ZV9pZBgGIAEoA0gAiAEBEhgKC2J1aWxkaW5nX2lkGAcgASgDSAGIAQFCCgoIX3NpdGVfaWRCDgoMX2J1aWxkaW5nX2lkIgsKCUdyb3VwSW5mbyKeAQoPRGV2aWNlU2V0TWVtYmVyEhkKEWRldmljZV9pZGVudGlmaWVyGAEgASgJEiwKCGFkZGVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgRyYWNrGAMgASgLMiAuZGV2aWNlX3NldC52MS5SYWNrTWVtYmVyRGV0YWlsc0gAQhAKDm1lbWJlcl9kZXRhaWxzIksKEVJhY2tNZW1iZXJEZXRhaWxzEjYKDXNsb3RfcG9zaXRpb24YASABKAsyHy5kZXZpY2Vfc2V0LnYxLlJhY2tTbG90UG9zaXRpb24iQQoQUmFja1Nsb3RQb3NpdGlvbhIUCgNyb3cYASABKAVCB7pIBBoCKAASFwoGY29sdW1uGAIgASgFQge6SAQaAigAIscCChZDcmVhdGVEZXZpY2VTZXRSZXF1ZXN0EjYKBHR5cGUYASABKA4yHC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFR5cGVCCrpIB4IBBBABIAASGwoFbGFiZWwYAiABKAlCDLpICcgBAXIEEAEYZBIdCgtkZXNjcmlwdGlvbhgDIAEoCUIIukgFcgMY9AMSLAoJcmFja19pbmZvGAQgASgLMhcuZGV2aWNlX3NldC52MS5SYWNrSW5mb0gAEi4KCmdyb3VwX2luZm8YBSABKAsyGC5kZXZpY2Vfc2V0LnYxLkdyb3VwSW5mb0gAEjcKD2RldmljZV9zZWxlY3RvchgGIAEoCzIZLmNvbW1vbi52MS5EZXZpY2VTZWxlY3RvckgBiAEBQg4KDHR5cGVfZGV0YWlsc0ISChBfZGV2aWNlX3NlbGVjdG9yIlwKF0NyZWF0ZURldmljZVNldFJlc3BvbnNlEiwKCmRldmljZV9zZXQYASABKAsyGC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldBITCgthZGRlZF9jb3VudBgCIAEoBSI1ChNHZXREZXZpY2VTZXRSZXF1ZXN0Eh4KDWRldmljZV9zZXRfaWQYASABKANCB7pIBCICIAAiRAoUR2V0RGV2aWNlU2V0UmVzcG9uc2USLAoKZGV2aWNlX3NldBgBIAEoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IrcCChZVcGRhdGVEZXZpY2VTZXRSZXF1ZXN0Eh4KDWRldmljZV9zZXRfaWQYASABKANCB7pIBCICIAASHQoFbGFiZWwYAiABKAlCCbpIBnIEEAEYZEgBiAEBEiIKC2Rlc2NyaXB0aW9uGAMgASgJQgi6SAVyAxj0A0gCiAEBEiwKCXJhY2tfaW5mbxgEIAEoCzIXLmRldmljZV9zZXQudjEuUmFja0luZm9IABIuCgpncm91cF9pbmZvGAUgASgLMhguZGV2aWNlX3NldC52MS5Hcm91cEluZm9IABIyCg9kZXZpY2Vfc2VsZWN0b3IYBiABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JCDgoMdHlwZV9kZXRhaWxzQggKBl9sYWJlbEIOCgxfZGVzY3JpcHRpb24iRwoXVXBkYXRlRGV2aWNlU2V0UmVzcG9uc2USLAoKZGV2aWNlX3NldBgBIAEoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IjgKFkRlbGV0ZURldmljZVNldFJlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACIZChdEZWxldGVEZXZpY2VTZXRSZXNwb25zZSLLAwoVTGlzdERldmljZVNldHNSZXF1ZXN0EjQKBHR5cGUYASABKA4yHC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFR5cGVCCLpIBYIBAhABEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABISCgpwYWdlX3Rva2VuGAMgASgJEiMKBHNvcnQYBCABKAsyFS5jb21tb24udjEuU29ydENvbmZpZxI3ChVlcnJvcl9jb21wb25lbnRfdHlwZXMYBSADKA4yGC5lcnJvcnMudjEuQ29tcG9uZW50VHlwZRIRCgV6b25lcxgGIAMoCUICGAESFAoMYnVpbGRpbmdfaWRzGAcgAygDEhsKE2luY2x1ZGVfbm9fYnVpbGRpbmcYCCABKAgSJQoJem9uZV9rZXlzGAkgAygLMhIuY29tbW9uLnYxLlpvbmVLZXkSIQoIc2l0ZV9pZHMYCiADKANCD7pIDJIBCRCACCIEIgIgABIaChJpbmNsdWRlX3VuYXNzaWduZWQYCyABKAgSQgoQdGVsZW1ldHJ5X3JhbmdlcxgMIAMoCzIoLmNvbW1vbi52MS5GbGVldExpc3RUZWxlbWV0cnlSYW5nZUZpbHRlciJ1ChZMaXN0RGV2aWNlU2V0c1Jlc3BvbnNlEi0KC2RldmljZV9zZXRzGAEgAygLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhMKC3RvdGFsX2NvdW50GAMgASgFIngKGEFkZERldmljZXNUb0dyb3VwUmVxdWVzdBIgCg90YXJnZXRfZ3JvdXBfaWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiMAoZQWRkRGV2aWNlc1RvR3JvdXBSZXNwb25zZRITCgthZGRlZF9jb3VudBgBIAEoAyJ9Ch1SZW1vdmVEZXZpY2VzRnJvbUdyb3VwUmVxdWVzdBIgCg90YXJnZXRfZ3JvdXBfaWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiNwoeUmVtb3ZlRGV2aWNlc0Zyb21Hcm91cFJlc3BvbnNlEhUKDXJlbW92ZWRfY291bnQYASABKAMirAEKG0xpc3REZXZpY2VTZXRNZW1iZXJzUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABISCgpwYWdlX3Rva2VuGAMgASgJEiEKCHNpdGVfaWRzGAQgAygDQg+6SAySAQkQgAgiBCICIAASGgoSaW5jbHVkZV91bmFzc2lnbmVkGAUgASgIImgKHExpc3REZXZpY2VTZXRNZW1iZXJzUmVzcG9uc2USLwoHbWVtYmVycxgBIAMoCzIeLmRldmljZV9zZXQudjEuRGV2aWNlU2V0TWVtYmVyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSJsChpHZXREZXZpY2VEZXZpY2VTZXRzUmVxdWVzdBIiChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIHukgEcgIQARIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlIkwKG0dldERldmljZURldmljZVNldHNSZXNwb25zZRItCgtkZXZpY2Vfc2V0cxgBIAMoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IpsBChpTZXRSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABEjkKCHBvc2l0aW9uGAMgASgLMh8uZGV2aWNlX3NldC52MS5SYWNrU2xvdFBvc2l0aW9uQga6SAPIAQEiWwobU2V0UmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlEhUKDWRldmljZV9zZXRfaWQYASABKAMSJQoEc2xvdBgCIAEoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3QiYgocQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABIh8KHUNsZWFyUmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlIjUKE0dldFJhY2tTbG90c1JlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACJYCghSYWNrU2xvdBIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRIxCghwb3NpdGlvbhgCIAEoCzIfLmRldmljZV9zZXQudjEuUmFja1Nsb3RQb3NpdGlvbiI+ChRHZXRSYWNrU2xvdHNSZXNwb25zZRImCgVzbG90cxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3Qi7QQKDkRldmljZVNldFN0YXRzEhUKDWRldmljZV9zZXRfaWQYASABKAMSFAoMZGV2aWNlX2NvdW50GAIgASgFEhcKD3JlcG9ydGluZ19jb3VudBgDIAEoBRIaChJ0b3RhbF9oYXNocmF0ZV90aHMYBCABKAESGgoSYXZnX2VmZmljaWVuY3lfanRoGAUgASgBEhYKDnRvdGFsX3Bvd2VyX2t3GAYgASgBEhkKEW1pbl90ZW1wZXJhdHVyZV9jGAcgASgBEhkKEW1heF90ZW1wZXJhdHVyZV9jGAggASgBEhUKDWhhc2hpbmdfY291bnQYCSABKAUSFAoMYnJva2VuX2NvdW50GAogASgFEhUKDW9mZmxpbmVfY291bnQYCyABKAUSFgoOc2xlZXBpbmdfY291bnQYDCABKAUSIAoYaGFzaHJhdGVfcmVwb3J0aW5nX2NvdW50GA0gASgFEiIKGmVmZmljaWVuY3lfcmVwb3J0aW5nX2NvdW50GA4gASgFEh0KFXBvd2VyX3JlcG9ydGluZ19jb3VudBgPIAEoBRIjCht0ZW1wZXJhdHVyZV9yZXBvcnRpbmdfY291bnQYECABKAUSIQoZY29udHJvbF9ib2FyZF9pc3N1ZV9jb3VudBgRIAEoBRIXCg9mYW5faXNzdWVfY291bnQYEiABKAUSHgoWaGFzaF9ib2FyZF9pc3N1ZV9jb3VudBgTIAEoBRIXCg9wc3VfaXNzdWVfY291bnQYFCABKAUSNAoNc2xvdF9zdGF0dXNlcxgVIAMoCzIdLmRldmljZV9zZXQudjEuUmFja1Nsb3RTdGF0dXMiMgoYR2V0RGV2aWNlU2V0U3RhdHNSZXF1ZXN0EhYKDmRldmljZV9zZXRfaWRzGAEgAygDIkkKGUdldERldmljZVNldFN0YXRzUmVzcG9uc2USLAoFc3RhdHMYASADKAsyHS5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFN0YXRzIl4KDlJhY2tTbG90U3RhdHVzEgsKA3JvdxgBIAEoBRIOCgZjb2x1bW4YAiABKAUSLwoGc3RhdHVzGAMgASgOMh8uZGV2aWNlX3NldC52MS5TbG90RGV2aWNlU3RhdHVzIhYKFExpc3RSYWNrWm9uZXNSZXF1ZXN0IiYKFUxpc3RSYWNrWm9uZXNSZXNwb25zZRINCgV6b25lcxgBIAMoCSIZChdMaXN0UmFja1pvbmVSZWZzUmVxdWVzdCI9ChhMaXN0UmFja1pvbmVSZWZzUmVzcG9uc2USIQoFem9uZXMYASADKAsyEi5jb21tb24udjEuWm9uZVJlZiIWChRMaXN0UmFja1R5cGVzUmVxdWVzdCI9CghSYWNrVHlwZRIMCgRyb3dzGAEgASgFEg8KB2NvbHVtbnMYAiABKAUSEgoKcmFja19jb3VudBgDIAEoBSJEChVMaXN0UmFja1R5cGVzUmVzcG9uc2USKwoKcmFja190eXBlcxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1R5cGUi1AIKD1NhdmVSYWNrUmVxdWVzdBIjCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAASACIAQESGwoFbGFiZWwYAiABKAlCDLpICcgBAXIEEAEYZBIyCglyYWNrX2luZm8YAyABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvQga6SAPIAQESOgoPZGV2aWNlX3NlbGVjdG9yGAQgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQESMQoQc2xvdF9hc3NpZ25tZW50cxgFIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3QSKQocZm9yY2VfY2xlYXJfY29uZmxpY3Rpbmdfc2l0ZRgGIAEoCEgBiAEBQhAKDl9kZXZpY2Vfc2V0X2lkQh8KHV9mb3JjZV9jbGVhcl9jb25mbGljdGluZ19zaXRlIrABChBTYXZlUmFja1Jlc3BvbnNlEiwKCmRldmljZV9zZXQYASABKAsyGC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldBIWCg5hc3NpZ25lZF9jb3VudBgCIAEoBRIdChVzaXRlX3JlYXNzaWduZWRfY291bnQYAyABKAUSNwoJY29uZmxpY3RzGAQgAygLMiQuZGV2aWNlX3NldC52MS5QZXJEZXZpY2VSYWNrQ29uZmxpY3Qi3QEKGkFzc2lnbkRldmljZXNUb1JhY2tSZXF1ZXN0EiQKDnRhcmdldF9yYWNrX2lkGAEgASgDQge6SAQiAiAASACIAQESOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQESKQocZm9yY2VfY2xlYXJfY29uZmxpY3Rpbmdfc2l0ZRgDIAEoCEgBiAEBQhEKD190YXJnZXRfcmFja19pZEIfCh1fZm9yY2VfY2xlYXJfY29uZmxpY3Rpbmdfc2l0ZSKkAQobQXNzaWduRGV2aWNlc1RvUmFja1Jlc3BvbnNlEhYKDmFzc2lnbmVkX2NvdW50GAEgASgDEh0KFXNpdGVfcmVhc3NpZ25lZF9jb3VudBgCIAEoAxIVCg1yZW1vdmVkX2NvdW50GAMgASgDEjcKCWNvbmZsaWN0cxgEIAMoCzIkLmRldmljZV9zZXQudjEuUGVyRGV2aWNlUmFja0NvbmZsaWN0Im4KFVBlckRldmljZVJhY2tDb25mbGljdBIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRI6CgZyZWFzb24YAiABKA4yKi5kZXZpY2Vfc2V0LnYxLlBlckRldmljZVJhY2tDb25mbGljdFJlYXNvbiplCg1EZXZpY2VTZXRUeXBlEh8KG0RFVklDRV9TRVRfVFlQRV9VTlNQRUNJRklFRBAAEhkKFURFVklDRV9TRVRfVFlQRV9HUk9VUBABEhgKFERFVklDRV9TRVRfVFlQRV9SQUNLEAIqtgEKDlJhY2tPcmRlckluZGV4EiAKHFJBQ0tfT1JERVJfSU5ERVhfVU5TUEVDSUZJRUQQABIgChxSQUNLX09SREVSX0lOREVYX0JPVFRPTV9MRUZUEAESHQoZUkFDS19PUkRFUl9JTkRFWF9UT1BfTEVGVBACEiEKHVJBQ0tfT1JERVJfSU5ERVhfQk9UVE9NX1JJR0hUEAMSHgoaUkFDS19PUkRFUl9JTkRFWF9UT1BfUklHSFQQBCpwCg9SYWNrQ29vbGluZ1R5cGUSIQodUkFDS19DT09MSU5HX1RZUEVfVU5TUEVDSUZJRUQQABIZChVSQUNLX0NPT0xJTkdfVFlQRV9BSVIQARIfChtSQUNLX0NPT0xJTkdfVFlQRV9JTU1FUlNJT04QAirdAQoQU2xvdERldmljZVN0YXR1cxIiCh5TTE9UX0RFVklDRV9TVEFUVVNfVU5TUEVDSUZJRUQQABIcChhTTE9UX0RFVklDRV9TVEFUVVNfRU1QVFkQARIeChpTTE9UX0RFVklDRV9TVEFUVVNfSEVBTFRIWRACEiYKIlNMT1RfREVWSUNFX1NUQVRVU19ORUVEU19BVFRFTlRJT04QAxIeChpTTE9UX0RFVklDRV9TVEFUVVNfT0ZGTElORRAEEh8KG1NMT1RfREVWSUNFX1NUQVRVU19TTEVFUElORxAFKoUBChtQZXJEZXZpY2VSYWNrQ29uZmxpY3RSZWFzb24SLworUEVSX0RFVklDRV9SQUNLX0NPTkZMSUNUX1JFQVNPTl9VTlNQRUNJRklFRBAAEjUKMVBFUl9ERVZJQ0VfUkFDS19DT05GTElDVF9SRUFTT05fREVWSUNFX0xPU0VTX1NJVEUQATKpDgoQRGV2aWNlU2V0U2VydmljZRJgCg9DcmVhdGVEZXZpY2VTZXQSJS5kZXZpY2Vfc2V0LnYxLkNyZWF0ZURldmljZVNldFJlcXVlc3QaJi5kZXZpY2Vfc2V0LnYxLkNyZWF0ZURldmljZVNldFJlc3BvbnNlElcKDEdldERldmljZVNldBIiLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0UmVxdWVzdBojLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0UmVzcG9uc2USYAoPVXBkYXRlRGV2aWNlU2V0EiUuZGV2aWNlX3NldC52MS5VcGRhdGVEZXZpY2VTZXRSZXF1ZXN0GiYuZGV2aWNlX3NldC52MS5VcGRhdGVEZXZpY2VTZXRSZXNwb25zZRJgCg9EZWxldGVEZXZpY2VTZXQSJS5kZXZpY2Vfc2V0LnYxLkRlbGV0ZURldmljZVNldFJlcXVlc3QaJi5kZXZpY2Vfc2V0LnYxLkRlbGV0ZURldmljZVNldFJlc3BvbnNlEl0KDkxpc3REZXZpY2VTZXRzEiQuZGV2aWNlX3NldC52MS5MaXN0RGV2aWNlU2V0c1JlcXVlc3QaJS5kZXZpY2Vfc2V0LnYxLkxpc3REZXZpY2VTZXRzUmVzcG9uc2USZgoRQWRkRGV2aWNlc1RvR3JvdXASJy5kZXZpY2Vfc2V0LnYxLkFkZERldmljZXNUb0dyb3VwUmVxdWVzdBooLmRldmljZV9zZXQudjEuQWRkRGV2aWNlc1RvR3JvdXBSZXNwb25zZRJ1ChZSZW1vdmVEZXZpY2VzRnJvbUdyb3VwEiwuZGV2aWNlX3NldC52MS5SZW1vdmVEZXZpY2VzRnJvbUdyb3VwUmVxdWVzdBotLmRldmljZV9zZXQudjEuUmVtb3ZlRGV2aWNlc0Zyb21Hcm91cFJlc3BvbnNlEm8KFExpc3REZXZpY2VTZXRNZW1iZXJzEiouZGV2aWNlX3NldC52MS5MaXN0RGV2aWNlU2V0TWVtYmVyc1JlcXVlc3QaKy5kZXZpY2Vfc2V0LnYxLkxpc3REZXZpY2VTZXRNZW1iZXJzUmVzcG9uc2USbAoTR2V0RGV2aWNlRGV2aWNlU2V0cxIpLmRldmljZV9zZXQudjEuR2V0RGV2aWNlRGV2aWNlU2V0c1JlcXVlc3QaKi5kZXZpY2Vfc2V0LnYxLkdldERldmljZURldmljZVNldHNSZXNwb25zZRJsChNTZXRSYWNrU2xvdFBvc2l0aW9uEikuZGV2aWNlX3NldC52MS5TZXRSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBoqLmRldmljZV9zZXQudjEuU2V0UmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlEnIKFUNsZWFyUmFja1Nsb3RQb3NpdGlvbhIrLmRldmljZV9zZXQudjEuQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBosLmRldmljZV9zZXQudjEuQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVzcG9uc2USVwoMR2V0UmFja1Nsb3RzEiIuZGV2aWNlX3NldC52MS5HZXRSYWNrU2xvdHNSZXF1ZXN0GiMuZGV2aWNlX3NldC52MS5HZXRSYWNrU2xvdHNSZXNwb25zZRJmChFHZXREZXZpY2VTZXRTdGF0cxInLmRldmljZV9zZXQudjEuR2V0RGV2aWNlU2V0U3RhdHNSZXF1ZXN0GiguZGV2aWNlX3NldC52MS5HZXREZXZpY2VTZXRTdGF0c1Jlc3BvbnNlEloKDUxpc3RSYWNrWm9uZXMSIy5kZXZpY2Vfc2V0LnYxLkxpc3RSYWNrWm9uZXNSZXF1ZXN0GiQuZGV2aWNlX3NldC52MS5MaXN0UmFja1pvbmVzUmVzcG9uc2USYwoQTGlzdFJhY2tab25lUmVmcxImLmRldmljZV9zZXQudjEuTGlzdFJhY2tab25lUmVmc1JlcXVlc3QaJy5kZXZpY2Vfc2V0LnYxLkxpc3RSYWNrWm9uZVJlZnNSZXNwb25zZRJaCg1MaXN0UmFja1R5cGVzEiMuZGV2aWNlX3NldC52MS5MaXN0UmFja1R5cGVzUmVxdWVzdBokLmRldmljZV9zZXQudjEuTGlzdFJhY2tUeXBlc1Jlc3BvbnNlEksKCFNhdmVSYWNrEh4uZGV2aWNlX3NldC52MS5TYXZlUmFja1JlcXVlc3QaHy5kZXZpY2Vfc2V0LnYxLlNhdmVSYWNrUmVzcG9uc2USbAoTQXNzaWduRGV2aWNlc1RvUmFjaxIpLmRldmljZV9zZXQudjEuQXNzaWduRGV2aWNlc1RvUmFja1JlcXVlc3QaKi5kZXZpY2Vfc2V0LnYxLkFzc2lnbkRldmljZXNUb1JhY2tSZXNwb25zZULDAQoRY29tLmRldmljZV9zZXQudjFCDkRldmljZVNldFByb3RvUAFaTWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2RldmljZV9zZXQvdjE7ZGV2aWNlX3NldHYxogIDRFhYqgIMRGV2aWNlU2V0LlYxygIMRGV2aWNlU2V0XFYx4gIYRGV2aWNlU2V0XFYxXEdQQk1ldGFkYXRh6gINRGV2aWNlU2V0OjpWMWIGcHJvdG8z", + "Ch5kZXZpY2Vfc2V0L3YxL2RldmljZV9zZXQucHJvdG8SDWRldmljZV9zZXQudjEi+AIKCURldmljZVNldBIKCgJpZBgBIAEoAxIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlEg0KBWxhYmVsGAMgASgJEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhQKDGRldmljZV9jb3VudBgFIAEoBRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgp1cGRhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIsCglyYWNrX2luZm8YCCABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvSAASLgoKZ3JvdXBfaW5mbxgJIAEoCzIYLmRldmljZV9zZXQudjEuR3JvdXBJbmZvSAASKwoJcGxhY2VtZW50GAogASgLMhguY29tbW9uLnYxLlBsYWNlbWVudFJlZnNCDgoMdHlwZV9kZXRhaWxzIogCCghSYWNrSW5mbxIVCgRyb3dzGAEgASgFQge6SAQaAiAAEhgKB2NvbHVtbnMYAiABKAVCB7pIBBoCIAASFQoEem9uZRgDIAEoCUIHukgEcgIYZBIyCgtvcmRlcl9pbmRleBgEIAEoDjIdLmRldmljZV9zZXQudjEuUmFja09yZGVySW5kZXgSNAoMY29vbGluZ190eXBlGAUgASgOMh4uZGV2aWNlX3NldC52MS5SYWNrQ29vbGluZ1R5cGUSFAoHc2l0ZV9pZBgGIAEoA0gAiAEBEhgKC2J1aWxkaW5nX2lkGAcgASgDSAGIAQFCCgoIX3NpdGVfaWRCDgoMX2J1aWxkaW5nX2lkIgsKCUdyb3VwSW5mbyKeAQoPRGV2aWNlU2V0TWVtYmVyEhkKEWRldmljZV9pZGVudGlmaWVyGAEgASgJEiwKCGFkZGVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIwCgRyYWNrGAMgASgLMiAuZGV2aWNlX3NldC52MS5SYWNrTWVtYmVyRGV0YWlsc0gAQhAKDm1lbWJlcl9kZXRhaWxzIksKEVJhY2tNZW1iZXJEZXRhaWxzEjYKDXNsb3RfcG9zaXRpb24YASABKAsyHy5kZXZpY2Vfc2V0LnYxLlJhY2tTbG90UG9zaXRpb24iQQoQUmFja1Nsb3RQb3NpdGlvbhIUCgNyb3cYASABKAVCB7pIBBoCKAASFwoGY29sdW1uGAIgASgFQge6SAQaAigAIscCChZDcmVhdGVEZXZpY2VTZXRSZXF1ZXN0EjYKBHR5cGUYASABKA4yHC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFR5cGVCCrpIB4IBBBABIAASGwoFbGFiZWwYAiABKAlCDLpICcgBAXIEEAEYZBIdCgtkZXNjcmlwdGlvbhgDIAEoCUIIukgFcgMY9AMSLAoJcmFja19pbmZvGAQgASgLMhcuZGV2aWNlX3NldC52MS5SYWNrSW5mb0gAEi4KCmdyb3VwX2luZm8YBSABKAsyGC5kZXZpY2Vfc2V0LnYxLkdyb3VwSW5mb0gAEjcKD2RldmljZV9zZWxlY3RvchgGIAEoCzIZLmNvbW1vbi52MS5EZXZpY2VTZWxlY3RvckgBiAEBQg4KDHR5cGVfZGV0YWlsc0ISChBfZGV2aWNlX3NlbGVjdG9yIlwKF0NyZWF0ZURldmljZVNldFJlc3BvbnNlEiwKCmRldmljZV9zZXQYASABKAsyGC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldBITCgthZGRlZF9jb3VudBgCIAEoBSI1ChNHZXREZXZpY2VTZXRSZXF1ZXN0Eh4KDWRldmljZV9zZXRfaWQYASABKANCB7pIBCICIAAiRAoUR2V0RGV2aWNlU2V0UmVzcG9uc2USLAoKZGV2aWNlX3NldBgBIAEoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IrcCChZVcGRhdGVEZXZpY2VTZXRSZXF1ZXN0Eh4KDWRldmljZV9zZXRfaWQYASABKANCB7pIBCICIAASHQoFbGFiZWwYAiABKAlCCbpIBnIEEAEYZEgBiAEBEiIKC2Rlc2NyaXB0aW9uGAMgASgJQgi6SAVyAxj0A0gCiAEBEiwKCXJhY2tfaW5mbxgEIAEoCzIXLmRldmljZV9zZXQudjEuUmFja0luZm9IABIuCgpncm91cF9pbmZvGAUgASgLMhguZGV2aWNlX3NldC52MS5Hcm91cEluZm9IABIyCg9kZXZpY2Vfc2VsZWN0b3IYBiABKAsyGS5jb21tb24udjEuRGV2aWNlU2VsZWN0b3JCDgoMdHlwZV9kZXRhaWxzQggKBl9sYWJlbEIOCgxfZGVzY3JpcHRpb24iRwoXVXBkYXRlRGV2aWNlU2V0UmVzcG9uc2USLAoKZGV2aWNlX3NldBgBIAEoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IjgKFkRlbGV0ZURldmljZVNldFJlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACIZChdEZWxldGVEZXZpY2VTZXRSZXNwb25zZSLLAwoVTGlzdERldmljZVNldHNSZXF1ZXN0EjQKBHR5cGUYASABKA4yHC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFR5cGVCCLpIBYIBAhABEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABISCgpwYWdlX3Rva2VuGAMgASgJEiMKBHNvcnQYBCABKAsyFS5jb21tb24udjEuU29ydENvbmZpZxI3ChVlcnJvcl9jb21wb25lbnRfdHlwZXMYBSADKA4yGC5lcnJvcnMudjEuQ29tcG9uZW50VHlwZRIRCgV6b25lcxgGIAMoCUICGAESFAoMYnVpbGRpbmdfaWRzGAcgAygDEhsKE2luY2x1ZGVfbm9fYnVpbGRpbmcYCCABKAgSJQoJem9uZV9rZXlzGAkgAygLMhIuY29tbW9uLnYxLlpvbmVLZXkSIQoIc2l0ZV9pZHMYCiADKANCD7pIDJIBCRCACCIEIgIgABIaChJpbmNsdWRlX3VuYXNzaWduZWQYCyABKAgSQgoQdGVsZW1ldHJ5X3JhbmdlcxgMIAMoCzIoLmNvbW1vbi52MS5GbGVldExpc3RUZWxlbWV0cnlSYW5nZUZpbHRlciJ1ChZMaXN0RGV2aWNlU2V0c1Jlc3BvbnNlEi0KC2RldmljZV9zZXRzGAEgAygLMhguZGV2aWNlX3NldC52MS5EZXZpY2VTZXQSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhMKC3RvdGFsX2NvdW50GAMgASgFIngKGEFkZERldmljZXNUb0dyb3VwUmVxdWVzdBIgCg90YXJnZXRfZ3JvdXBfaWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiMAoZQWRkRGV2aWNlc1RvR3JvdXBSZXNwb25zZRITCgthZGRlZF9jb3VudBgBIAEoAyJ9Ch1SZW1vdmVEZXZpY2VzRnJvbUdyb3VwUmVxdWVzdBIgCg90YXJnZXRfZ3JvdXBfaWQYASABKANCB7pIBCICIAASOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQEiNwoeUmVtb3ZlRGV2aWNlc0Zyb21Hcm91cFJlc3BvbnNlEhUKDXJlbW92ZWRfY291bnQYASABKAMirAEKG0xpc3REZXZpY2VTZXRNZW1iZXJzUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABISCgpwYWdlX3Rva2VuGAMgASgJEiEKCHNpdGVfaWRzGAQgAygDQg+6SAySAQkQgAgiBCICIAASGgoSaW5jbHVkZV91bmFzc2lnbmVkGAUgASgIImgKHExpc3REZXZpY2VTZXRNZW1iZXJzUmVzcG9uc2USLwoHbWVtYmVycxgBIAMoCzIeLmRldmljZV9zZXQudjEuRGV2aWNlU2V0TWVtYmVyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSJsChpHZXREZXZpY2VEZXZpY2VTZXRzUmVxdWVzdBIiChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCUIHukgEcgIQARIqCgR0eXBlGAIgASgOMhwuZGV2aWNlX3NldC52MS5EZXZpY2VTZXRUeXBlIkwKG0dldERldmljZURldmljZVNldHNSZXNwb25zZRItCgtkZXZpY2Vfc2V0cxgBIAMoCzIYLmRldmljZV9zZXQudjEuRGV2aWNlU2V0IpsBChpTZXRSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABEjkKCHBvc2l0aW9uGAMgASgLMh8uZGV2aWNlX3NldC52MS5SYWNrU2xvdFBvc2l0aW9uQga6SAPIAQEiWwobU2V0UmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlEhUKDWRldmljZV9zZXRfaWQYASABKAMSJQoEc2xvdBgCIAEoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3QiYgocQ2xlYXJSYWNrU2xvdFBvc2l0aW9uUmVxdWVzdBIeCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAAEiIKEWRldmljZV9pZGVudGlmaWVyGAIgASgJQge6SARyAhABIh8KHUNsZWFyUmFja1Nsb3RQb3NpdGlvblJlc3BvbnNlIjUKE0dldFJhY2tTbG90c1JlcXVlc3QSHgoNZGV2aWNlX3NldF9pZBgBIAEoA0IHukgEIgIgACJYCghSYWNrU2xvdBIZChFkZXZpY2VfaWRlbnRpZmllchgBIAEoCRIxCghwb3NpdGlvbhgCIAEoCzIfLmRldmljZV9zZXQudjEuUmFja1Nsb3RQb3NpdGlvbiI+ChRHZXRSYWNrU2xvdHNSZXNwb25zZRImCgVzbG90cxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3Qi7QQKDkRldmljZVNldFN0YXRzEhUKDWRldmljZV9zZXRfaWQYASABKAMSFAoMZGV2aWNlX2NvdW50GAIgASgFEhcKD3JlcG9ydGluZ19jb3VudBgDIAEoBRIaChJ0b3RhbF9oYXNocmF0ZV90aHMYBCABKAESGgoSYXZnX2VmZmljaWVuY3lfanRoGAUgASgBEhYKDnRvdGFsX3Bvd2VyX2t3GAYgASgBEhkKEW1pbl90ZW1wZXJhdHVyZV9jGAcgASgBEhkKEW1heF90ZW1wZXJhdHVyZV9jGAggASgBEhUKDWhhc2hpbmdfY291bnQYCSABKAUSFAoMYnJva2VuX2NvdW50GAogASgFEhUKDW9mZmxpbmVfY291bnQYCyABKAUSFgoOc2xlZXBpbmdfY291bnQYDCABKAUSIAoYaGFzaHJhdGVfcmVwb3J0aW5nX2NvdW50GA0gASgFEiIKGmVmZmljaWVuY3lfcmVwb3J0aW5nX2NvdW50GA4gASgFEh0KFXBvd2VyX3JlcG9ydGluZ19jb3VudBgPIAEoBRIjCht0ZW1wZXJhdHVyZV9yZXBvcnRpbmdfY291bnQYECABKAUSIQoZY29udHJvbF9ib2FyZF9pc3N1ZV9jb3VudBgRIAEoBRIXCg9mYW5faXNzdWVfY291bnQYEiABKAUSHgoWaGFzaF9ib2FyZF9pc3N1ZV9jb3VudBgTIAEoBRIXCg9wc3VfaXNzdWVfY291bnQYFCABKAUSNAoNc2xvdF9zdGF0dXNlcxgVIAMoCzIdLmRldmljZV9zZXQudjEuUmFja1Nsb3RTdGF0dXMiMgoYR2V0RGV2aWNlU2V0U3RhdHNSZXF1ZXN0EhYKDmRldmljZV9zZXRfaWRzGAEgAygDIkkKGUdldERldmljZVNldFN0YXRzUmVzcG9uc2USLAoFc3RhdHMYASADKAsyHS5kZXZpY2Vfc2V0LnYxLkRldmljZVNldFN0YXRzIl4KDlJhY2tTbG90U3RhdHVzEgsKA3JvdxgBIAEoBRIOCgZjb2x1bW4YAiABKAUSLwoGc3RhdHVzGAMgASgOMh8uZGV2aWNlX3NldC52MS5TbG90RGV2aWNlU3RhdHVzIhYKFExpc3RSYWNrWm9uZXNSZXF1ZXN0IiYKFUxpc3RSYWNrWm9uZXNSZXNwb25zZRINCgV6b25lcxgBIAMoCSIZChdMaXN0UmFja1pvbmVSZWZzUmVxdWVzdCI9ChhMaXN0UmFja1pvbmVSZWZzUmVzcG9uc2USIQoFem9uZXMYASADKAsyEi5jb21tb24udjEuWm9uZVJlZiIWChRMaXN0UmFja1R5cGVzUmVxdWVzdCI9CghSYWNrVHlwZRIMCgRyb3dzGAEgASgFEg8KB2NvbHVtbnMYAiABKAUSEgoKcmFja19jb3VudBgDIAEoBSJEChVMaXN0UmFja1R5cGVzUmVzcG9uc2USKwoKcmFja190eXBlcxgBIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1R5cGUi1AIKD1NhdmVSYWNrUmVxdWVzdBIjCg1kZXZpY2Vfc2V0X2lkGAEgASgDQge6SAQiAiAASACIAQESGwoFbGFiZWwYAiABKAlCDLpICcgBAXIEEAEYZBIyCglyYWNrX2luZm8YAyABKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tJbmZvQga6SAPIAQESOgoPZGV2aWNlX3NlbGVjdG9yGAQgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQESMQoQc2xvdF9hc3NpZ25tZW50cxgFIAMoCzIXLmRldmljZV9zZXQudjEuUmFja1Nsb3QSKQocZm9yY2VfY2xlYXJfY29uZmxpY3Rpbmdfc2l0ZRgGIAEoCEgBiAEBQhAKDl9kZXZpY2Vfc2V0X2lkQh8KHV9mb3JjZV9jbGVhcl9jb25mbGljdGluZ19zaXRlIrABChBTYXZlUmFja1Jlc3BvbnNlEiwKCmRldmljZV9zZXQYASABKAsyGC5kZXZpY2Vfc2V0LnYxLkRldmljZVNldBIWCg5hc3NpZ25lZF9jb3VudBgCIAEoBRIdChVzaXRlX3JlYXNzaWduZWRfY291bnQYAyABKAUSNwoJY29uZmxpY3RzGAQgAygLMiQuZGV2aWNlX3NldC52MS5QZXJEZXZpY2VSYWNrQ29uZmxpY3QimwIKGkFzc2lnbkRldmljZXNUb1JhY2tSZXF1ZXN0EiQKDnRhcmdldF9yYWNrX2lkGAEgASgDQge6SAQiAiAASACIAQESOgoPZGV2aWNlX3NlbGVjdG9yGAIgASgLMhkuY29tbW9uLnYxLkRldmljZVNlbGVjdG9yQga6SAPIAQESKQocZm9yY2VfY2xlYXJfY29uZmxpY3Rpbmdfc2l0ZRgDIAEoCEgBiAEBEjwKEHNsb3RfYXNzaWdubWVudHMYBCADKAsyFy5kZXZpY2Vfc2V0LnYxLlJhY2tTbG90Qgm6SAaSAQMQkE5CEQoPX3RhcmdldF9yYWNrX2lkQh8KHV9mb3JjZV9jbGVhcl9jb25mbGljdGluZ19zaXRlIqQBChtBc3NpZ25EZXZpY2VzVG9SYWNrUmVzcG9uc2USFgoOYXNzaWduZWRfY291bnQYASABKAMSHQoVc2l0ZV9yZWFzc2lnbmVkX2NvdW50GAIgASgDEhUKDXJlbW92ZWRfY291bnQYAyABKAMSNwoJY29uZmxpY3RzGAQgAygLMiQuZGV2aWNlX3NldC52MS5QZXJEZXZpY2VSYWNrQ29uZmxpY3QibgoVUGVyRGV2aWNlUmFja0NvbmZsaWN0EhkKEWRldmljZV9pZGVudGlmaWVyGAEgASgJEjoKBnJlYXNvbhgCIAEoDjIqLmRldmljZV9zZXQudjEuUGVyRGV2aWNlUmFja0NvbmZsaWN0UmVhc29uKmUKDURldmljZVNldFR5cGUSHwobREVWSUNFX1NFVF9UWVBFX1VOU1BFQ0lGSUVEEAASGQoVREVWSUNFX1NFVF9UWVBFX0dST1VQEAESGAoUREVWSUNFX1NFVF9UWVBFX1JBQ0sQAiq2AQoOUmFja09yZGVySW5kZXgSIAocUkFDS19PUkRFUl9JTkRFWF9VTlNQRUNJRklFRBAAEiAKHFJBQ0tfT1JERVJfSU5ERVhfQk9UVE9NX0xFRlQQARIdChlSQUNLX09SREVSX0lOREVYX1RPUF9MRUZUEAISIQodUkFDS19PUkRFUl9JTkRFWF9CT1RUT01fUklHSFQQAxIeChpSQUNLX09SREVSX0lOREVYX1RPUF9SSUdIVBAEKnAKD1JhY2tDb29saW5nVHlwZRIhCh1SQUNLX0NPT0xJTkdfVFlQRV9VTlNQRUNJRklFRBAAEhkKFVJBQ0tfQ09PTElOR19UWVBFX0FJUhABEh8KG1JBQ0tfQ09PTElOR19UWVBFX0lNTUVSU0lPThACKt0BChBTbG90RGV2aWNlU3RhdHVzEiIKHlNMT1RfREVWSUNFX1NUQVRVU19VTlNQRUNJRklFRBAAEhwKGFNMT1RfREVWSUNFX1NUQVRVU19FTVBUWRABEh4KGlNMT1RfREVWSUNFX1NUQVRVU19IRUFMVEhZEAISJgoiU0xPVF9ERVZJQ0VfU1RBVFVTX05FRURTX0FUVEVOVElPThADEh4KGlNMT1RfREVWSUNFX1NUQVRVU19PRkZMSU5FEAQSHwobU0xPVF9ERVZJQ0VfU1RBVFVTX1NMRUVQSU5HEAUqhQEKG1BlckRldmljZVJhY2tDb25mbGljdFJlYXNvbhIvCitQRVJfREVWSUNFX1JBQ0tfQ09ORkxJQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASNQoxUEVSX0RFVklDRV9SQUNLX0NPTkZMSUNUX1JFQVNPTl9ERVZJQ0VfTE9TRVNfU0lURRABMqkOChBEZXZpY2VTZXRTZXJ2aWNlEmAKD0NyZWF0ZURldmljZVNldBIlLmRldmljZV9zZXQudjEuQ3JlYXRlRGV2aWNlU2V0UmVxdWVzdBomLmRldmljZV9zZXQudjEuQ3JlYXRlRGV2aWNlU2V0UmVzcG9uc2USVwoMR2V0RGV2aWNlU2V0EiIuZGV2aWNlX3NldC52MS5HZXREZXZpY2VTZXRSZXF1ZXN0GiMuZGV2aWNlX3NldC52MS5HZXREZXZpY2VTZXRSZXNwb25zZRJgCg9VcGRhdGVEZXZpY2VTZXQSJS5kZXZpY2Vfc2V0LnYxLlVwZGF0ZURldmljZVNldFJlcXVlc3QaJi5kZXZpY2Vfc2V0LnYxLlVwZGF0ZURldmljZVNldFJlc3BvbnNlEmAKD0RlbGV0ZURldmljZVNldBIlLmRldmljZV9zZXQudjEuRGVsZXRlRGV2aWNlU2V0UmVxdWVzdBomLmRldmljZV9zZXQudjEuRGVsZXRlRGV2aWNlU2V0UmVzcG9uc2USXQoOTGlzdERldmljZVNldHMSJC5kZXZpY2Vfc2V0LnYxLkxpc3REZXZpY2VTZXRzUmVxdWVzdBolLmRldmljZV9zZXQudjEuTGlzdERldmljZVNldHNSZXNwb25zZRJmChFBZGREZXZpY2VzVG9Hcm91cBInLmRldmljZV9zZXQudjEuQWRkRGV2aWNlc1RvR3JvdXBSZXF1ZXN0GiguZGV2aWNlX3NldC52MS5BZGREZXZpY2VzVG9Hcm91cFJlc3BvbnNlEnUKFlJlbW92ZURldmljZXNGcm9tR3JvdXASLC5kZXZpY2Vfc2V0LnYxLlJlbW92ZURldmljZXNGcm9tR3JvdXBSZXF1ZXN0Gi0uZGV2aWNlX3NldC52MS5SZW1vdmVEZXZpY2VzRnJvbUdyb3VwUmVzcG9uc2USbwoUTGlzdERldmljZVNldE1lbWJlcnMSKi5kZXZpY2Vfc2V0LnYxLkxpc3REZXZpY2VTZXRNZW1iZXJzUmVxdWVzdBorLmRldmljZV9zZXQudjEuTGlzdERldmljZVNldE1lbWJlcnNSZXNwb25zZRJsChNHZXREZXZpY2VEZXZpY2VTZXRzEikuZGV2aWNlX3NldC52MS5HZXREZXZpY2VEZXZpY2VTZXRzUmVxdWVzdBoqLmRldmljZV9zZXQudjEuR2V0RGV2aWNlRGV2aWNlU2V0c1Jlc3BvbnNlEmwKE1NldFJhY2tTbG90UG9zaXRpb24SKS5kZXZpY2Vfc2V0LnYxLlNldFJhY2tTbG90UG9zaXRpb25SZXF1ZXN0GiouZGV2aWNlX3NldC52MS5TZXRSYWNrU2xvdFBvc2l0aW9uUmVzcG9uc2UScgoVQ2xlYXJSYWNrU2xvdFBvc2l0aW9uEisuZGV2aWNlX3NldC52MS5DbGVhclJhY2tTbG90UG9zaXRpb25SZXF1ZXN0GiwuZGV2aWNlX3NldC52MS5DbGVhclJhY2tTbG90UG9zaXRpb25SZXNwb25zZRJXCgxHZXRSYWNrU2xvdHMSIi5kZXZpY2Vfc2V0LnYxLkdldFJhY2tTbG90c1JlcXVlc3QaIy5kZXZpY2Vfc2V0LnYxLkdldFJhY2tTbG90c1Jlc3BvbnNlEmYKEUdldERldmljZVNldFN0YXRzEicuZGV2aWNlX3NldC52MS5HZXREZXZpY2VTZXRTdGF0c1JlcXVlc3QaKC5kZXZpY2Vfc2V0LnYxLkdldERldmljZVNldFN0YXRzUmVzcG9uc2USWgoNTGlzdFJhY2tab25lcxIjLmRldmljZV9zZXQudjEuTGlzdFJhY2tab25lc1JlcXVlc3QaJC5kZXZpY2Vfc2V0LnYxLkxpc3RSYWNrWm9uZXNSZXNwb25zZRJjChBMaXN0UmFja1pvbmVSZWZzEiYuZGV2aWNlX3NldC52MS5MaXN0UmFja1pvbmVSZWZzUmVxdWVzdBonLmRldmljZV9zZXQudjEuTGlzdFJhY2tab25lUmVmc1Jlc3BvbnNlEloKDUxpc3RSYWNrVHlwZXMSIy5kZXZpY2Vfc2V0LnYxLkxpc3RSYWNrVHlwZXNSZXF1ZXN0GiQuZGV2aWNlX3NldC52MS5MaXN0UmFja1R5cGVzUmVzcG9uc2USSwoIU2F2ZVJhY2sSHi5kZXZpY2Vfc2V0LnYxLlNhdmVSYWNrUmVxdWVzdBofLmRldmljZV9zZXQudjEuU2F2ZVJhY2tSZXNwb25zZRJsChNBc3NpZ25EZXZpY2VzVG9SYWNrEikuZGV2aWNlX3NldC52MS5Bc3NpZ25EZXZpY2VzVG9SYWNrUmVxdWVzdBoqLmRldmljZV9zZXQudjEuQXNzaWduRGV2aWNlc1RvUmFja1Jlc3BvbnNlQsMBChFjb20uZGV2aWNlX3NldC52MUIORGV2aWNlU2V0UHJvdG9QAVpNZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvZGV2aWNlX3NldC92MTtkZXZpY2Vfc2V0djGiAgNEWFiqAgxEZXZpY2VTZXQuVjHKAgxEZXZpY2VTZXRcVjHiAhhEZXZpY2VTZXRcVjFcR1BCTWV0YWRhdGHqAg1EZXZpY2VTZXQ6OlYxYgZwcm90bzM", [ file_google_protobuf_timestamp, file_buf_validate_validate, @@ -1698,6 +1698,33 @@ export type AssignDevicesToRackRequest = Message<"device_set.v1.AssignDevicesToR * @generated from field: optional bool force_clear_conflicting_site = 3; */ forceClearConflictingSite?: boolean | undefined; + + /** + * Slot placements for the devices being assigned. The rack-level + * analogue of buildings.v1.RackPlacement's optional aisle_index / + * position_in_aisle: the batch names only the devices it is changing, + * and each named device ends up either placed or explicitly unplaced. + * + * Per entry: `position` set places the device there, `position` unset + * CLEARS its slot. A device not named here is untouched, so one call + * can mix placed, unplaced and left-alone miners. Every named device + * is cleared before any position is written, which is what lets a + * relayout swap two occupied cells without tripping + * uk_rack_slot_position mid-batch. + * + * Empty (the default): no slot is written at all. A device already in + * the target rack keeps its slot; a device arriving from another rack + * starts unplaced, because its old slot died with its old membership. + * + * Every entry must name a device present in device_selector — a slot + * needs a membership row to hang off — and no two entries may share a + * device or a position. Positions are bounds checked against the + * target rack's rows/columns. Requires target_rack_id: an unassign has + * no rack to place into. + * + * @generated from field: repeated device_set.v1.RackSlot slot_assignments = 4; + */ + slotAssignments: RackSlot[]; }; /** @@ -2155,6 +2182,15 @@ export const DeviceSetService: GenService<{ * is rejected with InvalidArgument because moving every paired * device into a single rack is never the intended operation. * + * slot_assignments optionally carries each moved device's slot inside + * the target rack, so membership and placement land in the same + * transaction. This is the rack-level counterpart to + * buildings.v1.AssignRacksToBuilding: a delta that names only the + * children it changes, with placement optional per child. Prefer it + * over SaveRack for every edit — SaveRack replaces the rack's whole + * member set, so a stale client snapshot silently drops members + * added concurrently. + * * @generated from rpc device_set.v1.DeviceSetService.AssignDevicesToRack */ assignDevicesToRack: { diff --git a/client/src/protoFleet/api/useDeviceSets.ts b/client/src/protoFleet/api/useDeviceSets.ts index 49514e20f3..033633cdd2 100644 --- a/client/src/protoFleet/api/useDeviceSets.ts +++ b/client/src/protoFleet/api/useDeviceSets.ts @@ -196,6 +196,13 @@ interface AssignDevicesToRackProps { // miners' site. Default false: the server returns conflicts (surfaced // via onConflicts) and writes nothing. forceClearConflictingSite?: boolean; + // Optional slot placements, applied in the same transaction. One entry + // per miner whose placement changes: `position` set lands it there, + // `position` omitted clears its slot (in the rack, off the grid). A + // miner not named here keeps whatever slot it had, which is what makes + // this safe where saveRack was not. Every entry must name a miner in + // deviceIdentifiers; no two may share a miner or a cell. + slotAssignments?: RackSlot[]; signal?: AbortSignal; onSuccess?: (assignedCount: bigint, siteReassignedCount: bigint, removedCount: bigint) => void; // Fires when the server returns site-strip conflicts (no write @@ -731,11 +738,18 @@ const useDeviceSets = () => { // server error / network blip between the two calls can't orphan // miners from rack assignment (issue #420). Pass targetRackId // unset to clear rack membership without re-assigning. + // + // Prefer this over saveRack for every edit to an existing rack. This is + // a delta — it names only the miners it changes — whereas saveRack + // replaces the rack's entire member set, so a stale local snapshot + // silently drops miners another session added while the modal was open. + // Pass slotAssignments to move membership and placement in one call. const assignDevicesToRack = useCallback( async ({ targetRackId, deviceIdentifiers, forceClearConflictingSite, + slotAssignments, signal, onSuccess, onConflicts, @@ -763,6 +777,7 @@ const useDeviceSets = () => { targetRackId, deviceSelector, forceClearConflictingSite, + slotAssignments, }, { signal }, ); diff --git a/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.test.tsx b/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.test.tsx index 89e0acaf07..74cef28ac7 100644 --- a/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.test.tsx +++ b/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.test.tsx @@ -215,6 +215,42 @@ describe("BuildingSettingsModal — edit mode", () => { ); }); + it("disables Save until a field actually changes", () => { + const initial: BuildingFormValues = { + ...emptyBuildingFormValues(), + name: "Existing", + powerCapacityMw: 5, + aisles: 3, + racksPerAisle: 4, + }; + render( + , + ); + + const save = screen.getByTestId("building-settings-modal-save"); + // Save fires UpdateBuilding; with no edit there's nothing to write. + expect(save).toBeDisabled(); + + // Re-typing the same value in a different format is not a change — the + // gate compares the parsed form, not the raw text. + fireEvent.change(screen.getByTestId("building-settings-power-input"), { target: { value: "5.0" } }); + expect(save).toBeDisabled(); + + fireEvent.change(screen.getByTestId("building-settings-aisles-input"), { target: { value: "6" } }); + expect(save).not.toBeDisabled(); + + // Back to the original → clean again, not latched dirty. + fireEvent.change(screen.getByTestId("building-settings-aisles-input"), { target: { value: "3" } }); + expect(save).toBeDisabled(); + }); + it("Delete button fires onDeleteRequested", () => { const onDeleteRequested = vi.fn(); render( diff --git a/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.tsx b/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.tsx index 5d2ca3d7d8..e48f1f2414 100644 --- a/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.tsx +++ b/client/src/protoFleet/features/buildings/components/BuildingSettingsModal/BuildingSettingsModal.tsx @@ -210,7 +210,23 @@ const BuildingSettingsModal = (props: BuildingSettingsModalProps) => { // can't pick another site and needs the page to be reloaded. const siteStale = isCreate && siteIdText !== "" && !siteInOptions; const siteError = siteStale ? "Selected site is no longer available. Refresh and try again." : undefined; - const primaryDisabled = !nameValid || !siteValid || saving; + + // Edit-mode dirty gate. Compared against the same normalized shape + // buildValues produces, so trailing whitespace or a capacity retyped as + // "12.50" doesn't read as an edit. buildValues sets the *Error states as a + // side effect, so this parses independently rather than calling it. + const isDirty = useMemo( + () => + name.trim() !== initialValues.name || + parseNonNegative(powerText) !== initialValues.powerCapacityMw || + parseNonNegative(overheadText) !== initialValues.overheadKw || + parseNonNegativeInt(aislesText) !== initialValues.aisles || + parseNonNegativeInt(racksPerAisleText) !== initialValues.racksPerAisle, + [name, powerText, overheadText, aislesText, racksPerAisleText, initialValues], + ); + + // Create has no baseline to diff against, so it gates on validation only. + const primaryDisabled = !nameValid || !siteValid || saving || (props.mode === "edit" && !isDirty); const buttons = props.mode === "create" @@ -223,7 +239,9 @@ const BuildingSettingsModal = (props: BuildingSettingsModalProps) => { testId: "building-settings-modal-cancel", }, { - text: saving ? "Saving…" : "Save", + // Named for the write it performs (CreateBuilding) rather than a + // generic "Save" — the building exists once this lands. + text: saving ? "Creating…" : "Create building", variant: variants.primary, onClick: handlePrimary, disabled: primaryDisabled, diff --git a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.test.tsx b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.test.tsx index 65558f266a..f58c980c70 100644 --- a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.test.tsx +++ b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.test.tsx @@ -7,10 +7,10 @@ import ManageBuildingModal from "./ManageBuildingModal"; import { BuildingSchema } from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; import { DeviceSetSchema, RackInfoSchema } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; -// Reparent racks STAGE on confirm (parity with the miner-side promptReparent → -// setRackMiners): accepting the reparent warning folds the rack into the working -// set but writes nothing until the outer Save, which persists it via a -// member-only AssignRacksToBuilding. These tests drive that flow end to end. +// The rack picker owns building membership, so accepting the reparent warning +// commits the move immediately via a member-only AssignRacksToBuilding. +// Placement is the only thing that stays staged for the outer Save. These tests +// drive that flow end to end. const mockApi = vi.hoisted(() => ({ listBuildingsBySite: vi.fn(), listBuildings: vi.fn(), @@ -65,7 +65,7 @@ const openPickerAndPickBeta = async () => { await screen.findByText("Move this rack?"); }; -describe("ManageBuildingModal reparent commit-on-Continue", () => { +describe("ManageBuildingModal reparent commit-on-confirm", () => { beforeEach(() => { mockApi.listBuildingsBySite.mockReset(); mockApi.listBuildings.mockReset(); @@ -82,24 +82,26 @@ describe("ManageBuildingModal reparent commit-on-Continue", () => { ); }); - it("stages the reparent on Move without any RPC until Save", async () => { + it("commits the reparent on Move via a member-only assign", async () => { renderModal(); await openPickerAndPickBeta(); - // Neither picking nor confirming the warning writes anything. + // Picking alone writes nothing — the warning has to be accepted first. expect(mockApi.assignRacksToBuilding).not.toHaveBeenCalled(); await userEvent.click(screen.getByRole("button", { name: "Move" })); - expect(mockApi.assignRacksToBuilding).not.toHaveBeenCalled(); - // The staged rack persists on the outer Save via a member-only assign into - // this building (targetBuildingId → the rack moves out of its old building). - await userEvent.click(screen.getByTestId("manage-building-save")); + // Accepting commits membership: a member-only assign into this building + // (targetBuildingId → the rack moves out of its old building). No cell is + // chosen yet; placement is the operator's next step, on the outer Save. await waitFor(() => expect(mockApi.assignRacksToBuilding).toHaveBeenCalled()); const movedThisBuilding = mockApi.assignRacksToBuilding.mock.calls .map((c) => c[0]) .find((arg) => arg.targetBuildingId === 20n && arg.racks.some((r: { rackId: bigint }) => r.rackId === 2n)); expect(movedThisBuilding).toBeTruthy(); - expect(movedThisBuilding.racks).toContainEqual({ rackId: 2n }); // member-only; no cell chosen + expect(movedThisBuilding.racks).toContainEqual({ rackId: 2n }); + + // Membership is committed, so the placement Save has nothing left to write. + await waitFor(() => expect(screen.getByTestId("manage-building-save")).toBeDisabled()); }); it("leaves the working set untouched and writes nothing when the warning is cancelled", async () => { @@ -114,16 +116,18 @@ describe("ManageBuildingModal reparent commit-on-Continue", () => { expect(screen.getByTestId("manage-racks-modal-confirm")).toBeInTheDocument(); }); - it("does not refresh the host on dismiss after staging a reparent (nothing committed until Save)", async () => { - // Staging writes nothing server-side, so a plain dismiss must not fire the - // host refresh — there is no server change to reconcile. + it("refreshes the host as soon as the reparent commits, not on dismiss", async () => { + // The membership write changes the host's rack counts, so onSaved fires + // with the commit rather than waiting for a Save that may never come. const onSaved = vi.fn(); renderModal(onSaved); await openPickerAndPickBeta(); await userEvent.click(screen.getByRole("button", { name: "Move" })); + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + onSaved.mockClear(); await userEvent.click(screen.getByLabelText("Close dialog")); - expect(mockApi.assignRacksToBuilding).not.toHaveBeenCalled(); + // Nothing left staged, so the dismiss itself has nothing to reconcile. expect(onSaved).not.toHaveBeenCalled(); }); @@ -136,3 +140,62 @@ describe("ManageBuildingModal reparent commit-on-Continue", () => { expect(onSaved).not.toHaveBeenCalled(); }); }); + +describe("ManageBuildingModal Save dirty gate", () => { + beforeEach(() => { + mockApi.listBuildingsBySite.mockReset(); + mockApi.listBuildings.mockReset(); + mockApi.listBuildingRacks.mockReset(); + mockApi.assignRacksToBuilding.mockReset(); + mockListRacks.mockReset(); + // Alpha loads already placed at aisle 0, position 0 — so the working set + // starts clean against the snapshot. + mockApi.listBuildingRacks.mockImplementation(({ onSuccess }) => + onSuccess?.([{ rackId: 1n, rackLabel: "Alpha", aisleIndex: 0, positionInAisle: 0 }]), + ); + mockApi.listBuildingsBySite.mockImplementation(({ onSuccess }) => onSuccess?.([])); + mockApi.listBuildings.mockImplementation(({ onSuccess }) => onSuccess?.([])); + mockApi.assignRacksToBuilding.mockImplementation(({ onSuccess }) => onSuccess?.(0n)); + mockListRacks.mockImplementation(({ onSuccess }) => onSuccess?.([createRack(1n, "Alpha", 20n, 7n)])); + }); + + it("row-level Remove rack unassigns immediately", async () => { + renderModal(); + await screen.findByTestId("manage-building-assigned-rack-1"); + + await userEvent.click(screen.getByTestId("manage-building-remove-rack-1")); + + // Unassign = a member-only assign with no target building. + await waitFor(() => expect(mockApi.assignRacksToBuilding).toHaveBeenCalled()); + const call = mockApi.assignRacksToBuilding.mock.calls[0][0]; + expect(call.targetBuildingId).toBeUndefined(); + expect(call.racks).toEqual([{ rackId: 1n }]); + // The row drops once the write lands, and Save has nothing left to do. + await waitFor(() => expect(screen.queryByTestId("manage-building-assigned-rack-1")).not.toBeInTheDocument()); + expect(screen.getByTestId("manage-building-save")).toBeDisabled(); + }); + + it("keeps the row when the unassign fails", async () => { + mockApi.assignRacksToBuilding.mockImplementation(({ onError }) => onError?.("network down")); + renderModal(); + await screen.findByTestId("manage-building-assigned-rack-1"); + + await userEvent.click(screen.getByTestId("manage-building-remove-rack-1")); + + await waitFor(() => expect(screen.getByText(/Failed to update racks/)).toBeInTheDocument()); + expect(screen.getByTestId("manage-building-assigned-rack-1")).toBeInTheDocument(); + }); + + it("disables Save until a placement actually changes", async () => { + renderModal(); + // Loaded and clean — nothing to commit, so Save must not offer a no-op + // write (which previously toasted a save that dispatched no RPCs). + await waitFor(() => expect(screen.getByTestId("manage-building-save")).toBeDisabled()); + + // Move Alpha to a different cell: select the row, then click a free cell. + await userEvent.click(screen.getByTestId("manage-building-assigned-rack-1")); + await userEvent.click(screen.getByTestId("manage-building-grid-cell-1-1")); + + await waitFor(() => expect(screen.getByTestId("manage-building-save")).not.toBeDisabled()); + }); +}); diff --git a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.tsx b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.tsx index c4a61d0111..6ee96c36eb 100644 --- a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.tsx +++ b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/ManageBuildingModal.tsx @@ -3,7 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ManageRacksModal from "../ManageRacksModal"; import { type RackSelectionDelta } from "../ManageRacksModal/rackSelectionDelta"; import SearchRacksModal from "../SearchRacksModal"; -import { type AssignmentEntry, buildByNameAssignments, buildManualAssignments } from "./assignmentMath"; +import { + type AssignmentEntry, + buildByNameAssignments, + buildManualAssignments, + buildPlacementDelta, + isPlacementDeltaEmpty, +} from "./assignmentMath"; import BuildingGridPane from "./BuildingGridPane"; import { assignedRackScope, buildingRackScope } from "./buildingRackScope"; import BuildingRacksPane, { type AssignedRackRow } from "./BuildingRacksPane"; @@ -124,7 +130,9 @@ const ManageBuildingModal = ({ // Snapshot of the server's positions at load time so Save only fires // assignRacksToBuilding for racks whose position actually changed. Keyed // by rackId → "aisle:position" (or "unplaced") so we can string-compare. - const initialPlacementRef = useRef>(new Map()); + // State rather than a ref because the Save CTA's dirty gate is derived + // from it — a ref wouldn't re-derive when the load resolves. + const [initialPlacement, setInitialPlacement] = useState>(new Map()); // Synchronous in-flight guard for Save dispatches. setState batching // means the `isSaving` prop driving the button's `disabled` lags one @@ -163,7 +171,7 @@ const ManageBuildingModal = ({ : "unplaced", ); } - initialPlacementRef.current = snapshot; + setInitialPlacement(snapshot); setIsLoading(false); }, onError: (msg) => { @@ -212,6 +220,16 @@ const ManageBuildingModal = ({ return m; }, [activeAssignments]); + // The exact batches Save would dispatch. Derived here (not inside + // handleSave) so the Save CTA's dirty gate and the dispatch read the same + // diff — a gate computed separately could drift and either block a real + // change or let a no-op write through. + const placementDelta = useMemo( + () => buildPlacementDelta(entries, rackToCell, initialPlacement), + [entries, rackToCell, initialPlacement], + ); + const isDirty = !isPlacementDeltaEmpty(placementDelta); + // Assigned-racks list shown in the left pane. positionLabel is derived // from the activeAssignments so byName mode shows the auto-placement. const assignedRacks: AssignedRackRow[] = useMemo( @@ -318,16 +336,14 @@ const ManageBuildingModal = ({ setShowSearchRacks(true); }, []); - // Gate a reparent behind the warning dialog, then STAGE it on confirm — the - // rack joins the working set but nothing is written until the outer Save. - // Parity with the miner side (promptReparent → setRackMiners): a reparent is - // staged, and the actual move rides handleSave's member-only - // AssignRacksToBuilding (targetBuildingId → the rack moves out of its old - // building, cascading its miners) exactly like any newly-added rack. A staged - // rack is seeded into the picker, so buildRackPickerItem shows it as - // "in this building" on reopen — never a reassignment row that a later - // toggle-off / Select all / deselect could silently drop. Cancelling leaves - // the working set untouched and the picker open. + // Gate a reparent behind the warning dialog; confirming runs the caller's + // membership commit, which moves the rack into this building via a + // member-only AssignRacksToBuilding (targetBuildingId → the rack leaves its + // old building, cascading its miners) exactly like any newly-added rack. + // Once committed the rack is seeded into the picker, so buildRackPickerItem + // shows it as "in this building" on reopen — never a reassignment row that a + // later toggle-off / Select all / deselect could silently drop. Cancelling + // writes nothing and leaves the picker open. const promptReparent = useCallback((racks: ReparentedRack[], apply: () => void) => { setReparentConfirm({ racks, @@ -338,10 +354,101 @@ const ManageBuildingModal = ({ }); }, []); - // SearchRacksModal confirm — add the rack to the working set if missing - // and assign to the cell that was selected when the popover opened. When the - // rack is currently placed elsewhere, commit the move behind the reparent - // confirm (its miners move with it) before staging the cell. + // Chunked AssignRacksToBuilding dispatcher shared by the membership commits + // and the placement Save. Buildings can be 100×100 = 10,000 cells and this + // modal loads every page, so a large batch would otherwise blow past the + // proto's 1000-rack request cap. Chunks run sequentially so a mid-chain + // failure stops the chain; onChunkCommitted lets the caller tell "nothing + // landed" from "partial commit". + const dispatchAssign = useCallback( + async (racks: RackPlacementInput[], targetBuildingId?: bigint, onChunkCommitted?: () => void) => { + if (racks.length === 0) return; + for (let i = 0; i < racks.length; i += RACKS_PER_RPC) { + const chunk = racks.slice(i, i + RACKS_PER_RPC); + await new Promise((resolve, reject) => { + void assignRacksToBuilding({ + racks: chunk, + targetBuildingId, + onSuccess: () => resolve(), + onError: (msg) => reject(new Error(msg)), + }); + }); + onChunkCommitted?.(); + } + }, + [assignRacksToBuilding], + ); + + // Membership commit. The rack pickers own building membership, so a confirmed + // selection is written straight away and only placement stays staged for + // Save. Newcomers go in as member-only assigns (no cell) and land in the + // load-time snapshot as "unplaced", so freshly-committed membership doesn't + // read as pending placement dirt on the Save gate. + // + // The caller owns the working-set update — each entry point merges rows + // differently — and should skip it when this returns false. + const commitMembership = useCallback( + async (added: AssignmentEntry[], removed: bigint[]): Promise => { + if (savingRef.current) return false; + const currentIds = new Set(entries.map((e) => e.rackId.toString())); + const newcomers = added.filter((a) => !currentIds.has(a.rackId.toString())); + // Nothing to write. The caller may still have a placement change to + // stage (e.g. re-placing a rack that's already a member). + if (newcomers.length === 0 && removed.length === 0) return true; + + // Capacity guard — mirrors handleSave's and the server's + // AssignRacksToBuilding cap. Skipped when the grid is unconfigured + // (capacity 0): racks can join before a layout is set. + const capacity = aislesNum * racksPerAisleNum; + const nextCount = entries.length + newcomers.length - removed.length; + if (capacity > 0 && nextCount > capacity) { + setErrorMsg( + `This building has ${capacity} rack positions (${aislesNum} aisles × ${racksPerAisleNum} per aisle), ` + + `but the change would assign ${nextCount} racks. Remove some racks or increase the layout.`, + ); + return false; + } + + savingRef.current = true; + setErrorMsg(""); + setIsSaving(true); + try { + // Removals first: they free their cells before any newcomer lands. + await dispatchAssign( + removed.map((rackId) => ({ rackId })), + undefined, + ); + await dispatchAssign( + newcomers.map((a) => ({ rackId: a.rackId })), + building.id, + ); + } catch (err) { + const detail = err instanceof Error ? err.message : "Failed to update racks"; + setErrorMsg(`Failed to update racks: ${detail}.`); + return false; + } finally { + savingRef.current = false; + setIsSaving(false); + } + + const removedSet = new Set(removed.map((id) => id.toString())); + setInitialPlacement((prev) => { + const next = new Map(prev); + for (const id of removedSet) next.delete(id); + for (const a of newcomers) next.set(a.rackId.toString(), "unplaced"); + return next; + }); + // Rack counts changed on the host's building cache. + onSaved?.(building); + return true; + }, + [entries, aislesNum, racksPerAisleNum, dispatchAssign, building, onSaved], + ); + + // SearchRacksModal confirm — commit the rack into this building if it isn't + // a member yet, then stage it at the cell that was selected when the popover + // opened. A rack currently placed elsewhere goes behind the reparent confirm + // (its miners move with it) before either happens. const handleSearchRackConfirm = useCallback( (rackId: bigint, label: string, reparent?: ReparentedRack) => { const targetKey = selectedCellKey; @@ -349,7 +456,9 @@ const ManageBuildingModal = ({ setShowSearchRacks(false); return; } - const apply = () => { + const apply = async () => { + const ok = await commitMembership([{ rackId, label }], []); + if (!ok) return; const { aisle, position } = parseCellKey(targetKey); setEntries((prev) => { const idStr = rackId.toString(); @@ -371,73 +480,72 @@ const ManageBuildingModal = ({ setShowSearchRacks(false); }; if (reparent) { - promptReparent([reparent], apply); + promptReparent([reparent], () => void apply()); } else { - apply(); + void apply(); } }, - [selectedCellKey, promptReparent], + [selectedCellKey, promptReparent, commitMembership], ); - const handleRemoveRack = useCallback((rackId: bigint) => { - setEntries((prev) => prev.filter((e) => e.rackId !== rackId)); - setSelectedRackId((prev) => (prev === rackId ? null : prev)); - }, []); + // Row-level "Remove rack" — an immediate unassign (the rack moves out of the + // building; it is not deleted). The row drops only once the write lands. + const handleRemoveRack = useCallback( + async (rackId: bigint) => { + const ok = await commitMembership([], [rackId]); + if (!ok) return; + setEntries((prev) => prev.filter((e) => e.rackId !== rackId)); + setSelectedRackId((prev) => (prev === rackId ? null : prev)); + }, + [commitMembership], + ); const currentRackIds = useMemo(() => entries.map((e) => e.rackId), [entries]); - // ManageRacksModal confirm — apply the delta against the working - // set. `added` joins entries (unplaced) without disturbing existing - // positions; `removed` drops only those entries. Racks not in either - // list are untouched, so a seeded rack that didn't appear in the - // picker's listRacks response (race / paging gap) is preserved. - // `delta.reassigned` (racks currently placed elsewhere) commits the move - // behind the reparent confirm — their miners move with them — before the - // working-set change lands. + // ManageRacksModal Save — the picker owns building membership, so the delta is + // written here rather than staged. `added` joins entries unplaced (placement + // is still the operator's to set, on this modal's Save); `removed` drops only + // those entries. Racks in neither list are untouched, so a seeded rack the + // picker's listRacks response omitted (race / paging gap) is preserved. + // `delta.reassigned` (racks currently in another building) goes behind the + // reparent confirm — their miners move with them. const handleManageRacksConfirm = useCallback( (delta: RackSelectionDelta) => { - const apply = () => { + const apply = async () => { const removedSet = new Set(delta.removed.map((id) => id.toString())); - setEntries((prev) => { - const kept = prev.filter((e) => !removedSet.has(e.rackId.toString())); - const knownIds = new Set(kept.map((e) => e.rackId.toString())); - const newcomers: AssignmentEntry[] = []; - for (const a of delta.added) { - if (knownIds.has(a.rackId.toString())) continue; - newcomers.push({ rackId: a.rackId, label: a.label }); - } - return [...kept, ...newcomers]; - }); + const kept = entries.filter((e) => !removedSet.has(e.rackId.toString())); + const knownIds = new Set(kept.map((e) => e.rackId.toString())); + const newcomers: AssignmentEntry[] = []; + for (const a of delta.added) { + if (knownIds.has(a.rackId.toString())) continue; + newcomers.push({ rackId: a.rackId, label: a.label }); + } + // Failure leaves the picker open with the selection intact to retry. + const ok = await commitMembership(newcomers, delta.removed); + if (!ok) return; + setEntries([...kept, ...newcomers]); setSelectedRackId(null); setSelectedCellKey(null); setShowManageRacks(false); }; if (delta.reassigned.length > 0) { - promptReparent(delta.reassigned, apply); + promptReparent(delta.reassigned, () => void apply()); } else { - apply(); + void apply(); } }, - [promptReparent], + [entries, promptReparent, commitMembership], ); - // Save: walk activeAssignments, diff against the load-time snapshot, and - // fire AssignRacksToBuilding once per target building bucket. - // - // All racks staying in this building (placements, unplacements, - // swaps, "move into occupied cell") ship as a single mixed batch. - // The server's AssignRacksToBuilding transaction now runs a two-pass - // write internally — pass 1 clears every requested rack's cell, then - // pass 2 writes the new (aisle, position) values — so the partial - // unique index uk_device_set_rack_building_position can't collide - // mid-batch. That removes the old client-side vacate-then-place - // split (and its "retry to finish saving" partial-failure path). - // - // Racks removed from this building go in a second call with - // targetBuildingId=undefined since they need a different building - // bucket. Layout writes live in BuildingSettingsModal. + // Save owns rack placement only — membership commits in the pickers and + // layout lives in BuildingSettingsModal. Dispatches placementDelta's buckets + // as AssignRacksToBuilding calls against building.id. const handleSave = useCallback(async () => { if (savingRef.current) return; + // Defensive: the CTA is dirty-gated, so a clean save shouldn't be + // reachable. Bail rather than fall through to the success toast, which + // would report a save that dispatched nothing. + if (isPlacementDeltaEmpty(placementDelta)) return; // Capacity guard — mirrors ManageRackModal's slot check and the // server's AssignRacksToBuilding cap. A building holds at most @@ -459,140 +567,35 @@ const ManageBuildingModal = ({ setErrorMsg(""); setIsSaving(true); try { - const initial = initialPlacementRef.current; - const currentIds = new Set(entries.map((e) => e.rackId.toString())); - - const inBuilding: RackPlacementInput[] = []; - const unassign: RackPlacementInput[] = []; - - for (const entry of entries) { - const idStr = entry.rackId.toString(); - const placedKey = rackToCell.get(idStr); - const next = placedKey - ? (() => { - const { aisle, position } = parseCellKey(placedKey); - return `${aisle}:${position}`; - })() - : "unplaced"; - const prior = initial.get(idStr) ?? "missing"; - if (prior === next) continue; - - // Single mixed batch. - // - placedKey present → place at the new (aisle, position). - // Covers both first-time placement and moves; the server's - // pass-1 clear handles any prior occupant inside the batch. - // - placedKey absent + prior previously placed → send a - // member-only entry. The server NULLs the rack's cell in - // pass 1 (no pass-2 write because no position is supplied). - // - placedKey absent + prior === "missing" → rack is new to - // the working set with no chosen cell yet. Send a member- - // only assign so the BE links the rack to this building - // even without a position. Without this branch, racks - // added via Manage racks but never dragged to a cell - // silently drop on save. - if (placedKey) { - const { aisle, position } = parseCellKey(placedKey); - inBuilding.push({ - rackId: entry.rackId, - aisleIndex: aisle, - positionInAisle: position, - }); - } else { - inBuilding.push({ rackId: entry.rackId }); - } - } + const { unassign, inBuildingVacate, inBuildingPlace } = placementDelta; - // Racks removed from this building (in snapshot, not in entries) - // need an explicit unassign — different target building bucket so - // they can't ride the in-building batch. - for (const idStr of initial.keys()) { - if (currentIds.has(idStr)) continue; - unassign.push({ rackId: BigInt(idStr) }); - } - - // Buildings can be 100×100 = 10,000 cells, and this modal loads - // every page, so a large floor-plan save with >1000 changed/ - // removed racks would otherwise hit request validation. Chunk - // each phase into RPC-sized batches (RACKS_PER_RPC), dispatched - // sequentially so a mid-chain failure stops the chain (handled by - // the catch blocks below). Vacate-before-place is enforced across - // chunks by the two-pass dispatch below — the server only orders - // clear-then-place within a single RPC, so unassigns and cell- - // clears must all complete before any place runs. // Tracks whether any chunk has committed so the catch below can - // distinguish "nothing landed" from "partial commit" — operator + // distinguish "nothing landed" from "partial commit" — the operator // needs to know to refresh before retrying when chunks N..M ran // before chunk N+1 failed. let savedAtLeastOne = false; - const dispatch = async (racks: RackPlacementInput[], targetBuildingId?: bigint) => { - if (racks.length === 0) return; - for (let i = 0; i < racks.length; i += RACKS_PER_RPC) { - const chunk = racks.slice(i, i + RACKS_PER_RPC); - await new Promise((resolve, reject) => { - void assignRacksToBuilding({ - racks: chunk, - targetBuildingId, - onSuccess: () => resolve(), - onError: (msg) => reject(new Error(msg)), - }); - }); - savedAtLeastOne = true; - } + const onChunk = () => { + savedAtLeastOne = true; }; - // Two-pass shape across chunks: vacate ALL cells before placing - // ANY rack at a new cell. The server's clear-then-place ordering - // only applies within a single AssignRacksToBuilding tx, so a - // >1000-rack save where chunk 2 still owns the cell chunk 1 is - // trying to claim would trip uk_device_set_rack_building_position. - // - // Partition the in-building bucket so the vacate pass also clears - // the OLD cell of every mover — a rack with both a snapshot - // position and a new place entry. Otherwise a cross-chunk swap - // (rack A's new cell is rack B's old cell, A lands in chunk 1, B - // in chunk 2) would still trip the partial unique index because - // B's old cell wouldn't vacate until chunk 2 runs. - // - vacate entries (no aisle/position) — racks staying in the - // building but clearing their cell. Includes: - // * explicit cell-clear entries built above, - // * a synthetic pre-place vacate for every mover, dedup'd by - // rackId so we never send two clears for the same rack. - // - place entries (with aisle/position) — racks landing at a - // specific cell. These can only run after every vacate above - // (plus the unassign bucket) has committed. - const inBuildingVacate: RackPlacementInput[] = []; - const inBuildingPlace: RackPlacementInput[] = []; - const seenVacate = new Set(); - for (const entry of inBuilding) { - const idStr = entry.rackId.toString(); - const prior = initial.get(idStr) ?? "missing"; - const wasPlaced = prior !== "unplaced" && prior !== "missing"; - - if (entry.aisleIndex !== undefined && entry.positionInAisle !== undefined) { - // Mover (had a prior cell) → schedule a pre-place vacate so - // its old cell is free before any placement chunk runs. - if (wasPlaced && !seenVacate.has(idStr)) { - inBuildingVacate.push({ rackId: entry.rackId }); - seenVacate.add(idStr); - } - inBuildingPlace.push(entry); - } else if (!seenVacate.has(idStr)) { - // Already a cell-clear-in-place entry. - inBuildingVacate.push({ rackId: entry.rackId }); - seenVacate.add(idStr); - } - } - try { - // Pass 1: all vacates (unassign bucket + in-building cell- - // clears). dispatch short-circuits when the list is empty. - await dispatch(unassign, undefined); - await dispatch(inBuildingVacate, building.id); + // Two-pass shape across chunks: vacate ALL cells (buildPlacementDelta + // already folded each mover's old cell into inBuildingVacate) before + // placing ANY rack at a new cell. The server's clear-then-place + // ordering only applies within a single AssignRacksToBuilding tx, so + // a >1000-rack save where chunk 2 still owns the cell chunk 1 is + // trying to claim would trip uk_device_set_rack_building_position. + // + // Pass 1: all vacates. The unassign bucket is normally empty now that + // removals commit in the pickers — it stays wired as a reconcile path + // for any entry that leaves the list without a write. + await dispatchAssign(unassign, undefined, onChunk); + await dispatchAssign(inBuildingVacate, building.id, onChunk); // Pass 2: all places. By now every cell that will be reused // has been vacated, so no two writes collide on the partial // unique index — even across >1000-rack chunked saves. - await dispatch(inBuildingPlace, building.id); + await dispatchAssign(inBuildingPlace, building.id, onChunk); } catch (err) { const detail = err instanceof Error ? err.message : "Failed to save rack positions"; if (savedAtLeastOne) { @@ -605,14 +608,14 @@ const ManageBuildingModal = ({ return; } - pushToast({ message: `Building "${building.name}" saved`, status: STATUSES.success }); + pushToast({ message: `Rack positions saved`, status: STATUSES.success }); onSaved?.(building); onDismiss(); } finally { savingRef.current = false; setIsSaving(false); } - }, [building, rackToCell, entries, aislesNum, racksPerAisleNum, assignRacksToBuilding, onSaved, onDismiss]); + }, [building, placementDelta, entries, aislesNum, racksPerAisleNum, dispatchAssign, onSaved, onDismiss]); if (!open) return null; @@ -627,9 +630,9 @@ const ManageBuildingModal = ({ setShowManageRacks(false)} onConfirm={handleManageRacksConfirm} + saving={isSaving} /> ) : null} @@ -763,8 +771,8 @@ const ManageBuildingModal = ({ setReparentConfirm(null)} onConfirm={reparentConfirm.onConfirm} /> diff --git a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.test.ts b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.test.ts index 79b20d3925..92cd9451ed 100644 --- a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.test.ts +++ b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; -import { type AssignmentEntry, buildByNameAssignments, buildManualAssignments } from "./assignmentMath"; -import { cellKey } from "./types"; +import { + type AssignmentEntry, + buildByNameAssignments, + buildManualAssignments, + buildPlacementDelta, + isPlacementDeltaEmpty, +} from "./assignmentMath"; +import { cellKey, type GridCellKey } from "./types"; const entry = (id: bigint, label: string, aisle?: number, position?: number): AssignmentEntry => ({ rackId: id, @@ -84,3 +90,83 @@ describe("buildManualAssignments", () => { expect(buildManualAssignments([entry(1n, "A", 0, 0)], 3, 0)).toEqual({}); }); }); + +describe("buildPlacementDelta", () => { + const cells = (pairs: [bigint, number, number][]): Map => + new Map(pairs.map(([id, aisle, position]) => [id.toString(), cellKey(aisle, position)])); + + it("is empty when the working set matches the snapshot", () => { + const delta = buildPlacementDelta( + [entry(1n, "A", 0, 0), entry(2n, "B")], + cells([[1n, 0, 0]]), + new Map([ + ["1", "0:0"], + ["2", "unplaced"], + ]), + ); + expect(delta).toEqual({ unassign: [], inBuildingVacate: [], inBuildingPlace: [] }); + expect(isPlacementDeltaEmpty(delta)).toBe(true); + }); + + it("sends a member-only assign for a rack added but never placed", () => { + // Racks added via Manage racks and never dragged to a cell must still + // link to the building, or they silently drop on save. + const delta = buildPlacementDelta([entry(9n, "New")], cells([]), new Map()); + expect(delta.inBuildingVacate).toEqual([{ rackId: 9n }]); + expect(delta.inBuildingPlace).toEqual([]); + expect(isPlacementDeltaEmpty(delta)).toBe(false); + }); + + it("splits a mover into a pre-place vacate plus a place", () => { + const delta = buildPlacementDelta([entry(1n, "A", 1, 2)], cells([[1n, 1, 2]]), new Map([["1", "0:0"]])); + expect(delta.inBuildingVacate).toEqual([{ rackId: 1n }]); + expect(delta.inBuildingPlace).toEqual([{ rackId: 1n, aisleIndex: 1, positionInAisle: 2 }]); + }); + + it("emits no pre-place vacate for a first-time placement", () => { + const delta = buildPlacementDelta([entry(1n, "A", 0, 1)], cells([[1n, 0, 1]]), new Map([["1", "unplaced"]])); + expect(delta.inBuildingVacate).toEqual([]); + expect(delta.inBuildingPlace).toEqual([{ rackId: 1n, aisleIndex: 0, positionInAisle: 1 }]); + }); + + it("vacates in place when a placed rack loses its cell", () => { + const delta = buildPlacementDelta([entry(1n, "A")], cells([]), new Map([["1", "0:0"]])); + expect(delta.inBuildingVacate).toEqual([{ rackId: 1n }]); + expect(delta.inBuildingPlace).toEqual([]); + }); + + it("unassigns racks dropped from the working set", () => { + const delta = buildPlacementDelta( + [entry(1n, "A", 0, 0)], + cells([[1n, 0, 0]]), + new Map([ + ["1", "0:0"], + ["2", "0:1"], + ]), + ); + expect(delta.unassign).toEqual([{ rackId: 2n }]); + expect(delta.inBuildingVacate).toEqual([]); + expect(delta.inBuildingPlace).toEqual([]); + }); + + it("orders a swap so both old cells vacate before either place", () => { + // A and B trade cells — the partial unique index would collide if a + // place ran before the counterpart's old cell was cleared. + const delta = buildPlacementDelta( + [entry(1n, "A", 0, 1), entry(2n, "B", 0, 0)], + cells([ + [1n, 0, 1], + [2n, 0, 0], + ]), + new Map([ + ["1", "0:0"], + ["2", "0:1"], + ]), + ); + expect(delta.inBuildingVacate).toEqual([{ rackId: 1n }, { rackId: 2n }]); + expect(delta.inBuildingPlace).toEqual([ + { rackId: 1n, aisleIndex: 0, positionInAisle: 1 }, + { rackId: 2n, aisleIndex: 0, positionInAisle: 0 }, + ]); + }); +}); diff --git a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.ts b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.ts index da603fff0d..e760fec148 100644 --- a/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.ts +++ b/client/src/protoFleet/features/buildings/components/ManageBuildingModal/assignmentMath.ts @@ -2,7 +2,8 @@ // the bounds-drop and alphabetical-fill logic can be unit-tested // without standing up the full modal. -import { cellKey, type GridCellKey } from "./types"; +import { cellKey, type GridCellKey, parseCellKey } from "./types"; +import { type RackPlacementInput } from "@/protoFleet/api/buildings"; export interface AssignmentEntry { rackId: bigint; @@ -54,3 +55,80 @@ export const buildManualAssignments = ( } return out; }; + +// The AssignRacksToBuilding batches a Save would dispatch, diffed from the +// load-time snapshot. Empty across all three buckets means the working set +// matches the server — the Save CTA is gated on that so a clean modal can't +// fire a no-op write (or toast a save that never happened). +export interface PlacementDelta { + // Racks that left this building — dispatched with targetBuildingId + // undefined, so they can't ride the in-building batch. + unassign: RackPlacementInput[]; + // Racks staying in the building whose cell must be cleared. Includes both + // explicit unplacements and a synthetic pre-place vacate for every mover, + // so pass 1 frees every cell pass 2 will claim. + inBuildingVacate: RackPlacementInput[]; + // Racks landing at a specific (aisle, position). Must run after every + // vacate above, or a cross-chunk swap trips + // uk_device_set_rack_building_position. + inBuildingPlace: RackPlacementInput[]; +} + +export const isPlacementDeltaEmpty = (delta: PlacementDelta): boolean => + delta.unassign.length === 0 && delta.inBuildingVacate.length === 0 && delta.inBuildingPlace.length === 0; + +// Diff the working set against the load-time snapshot (rackId → +// "aisle:position" | "unplaced") and bucket the result into the two-pass +// dispatch shape handleSave sends. +export const buildPlacementDelta = ( + entries: AssignmentEntry[], + rackToCell: Map, + initial: Map, +): PlacementDelta => { + const unassign: RackPlacementInput[] = []; + const inBuildingVacate: RackPlacementInput[] = []; + const inBuildingPlace: RackPlacementInput[] = []; + const seenVacate = new Set(); + + for (const entry of entries) { + const idStr = entry.rackId.toString(); + const placedKey = rackToCell.get(idStr); + const next = placedKey + ? (() => { + const { aisle, position } = parseCellKey(placedKey); + return `${aisle}:${position}`; + })() + : "unplaced"; + const prior = initial.get(idStr) ?? "missing"; + if (prior === next) continue; + + if (placedKey) { + // Placement or move. A mover's old cell needs a pre-place vacate so + // it's free before any placement chunk runs. + const wasPlaced = prior !== "unplaced" && prior !== "missing"; + if (wasPlaced && !seenVacate.has(idStr)) { + inBuildingVacate.push({ rackId: entry.rackId }); + seenVacate.add(idStr); + } + const { aisle, position } = parseCellKey(placedKey); + inBuildingPlace.push({ rackId: entry.rackId, aisleIndex: aisle, positionInAisle: position }); + } else if (!seenVacate.has(idStr)) { + // No cell chosen. Either a cell-clear (prior was placed) or a rack + // newly added to the working set that was never dragged to a cell — + // both ship as a member-only assign so the BE links/keeps the rack in + // this building. Without the latter, racks added via Manage racks but + // never placed would silently drop on save. + inBuildingVacate.push({ rackId: entry.rackId }); + seenVacate.add(idStr); + } + } + + // Racks in the snapshot but no longer in the working set. + const currentIds = new Set(entries.map((e) => e.rackId.toString())); + for (const idStr of initial.keys()) { + if (currentIds.has(idStr)) continue; + unassign.push({ rackId: BigInt(idStr) }); + } + + return { unassign, inBuildingVacate, inBuildingPlace }; +}; diff --git a/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.test.tsx b/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.test.tsx index 527f7c7811..ba844b4793 100644 --- a/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.test.tsx +++ b/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.test.tsx @@ -233,12 +233,14 @@ describe("ManageRacksModal show-assigned toggle (server-side)", () => { await waitFor(() => expect(screen.getByText("Beta")).toBeInTheDocument()); await userEvent.click(rowCheckbox(1)); // reparent pick (Beta) + await waitFor(() => expect(screen.getByTestId("manage-racks-modal-confirm")).toBeEnabled()); await userEvent.click(screen.getByLabelText("Show assigned racks")); // toggle off - await userEvent.click(screen.getByTestId("manage-racks-modal-confirm")); - const delta = onConfirm.mock.calls[0][0]; - expect(delta.reassigned).toEqual([]); - expect(delta.added.map((a: { rackId: bigint }) => a.rackId)).not.toContain(2n); + // Dropping Beta empties the delta, so Save closes back up — there's nothing + // left to write. Clicking it is a no-op. + await waitFor(() => expect(screen.getByTestId("manage-racks-modal-confirm")).toBeDisabled()); + await userEvent.click(screen.getByTestId("manage-racks-modal-confirm")); + expect(onConfirm).not.toHaveBeenCalled(); }); it("allows an explicit single per-row reparent pick through the delta", async () => { @@ -281,14 +283,30 @@ describe("ManageRacksModal show-assigned toggle (server-side)", () => { it("never reports a seeded rack absent from the fetch as removed", async () => { // A seeded rack (id 3) that the eligibility-pinned fetch doesn't return // (paging gap / soft-delete window) must be left alone, not unassigned. + // Gamma (no building) gives the save something real to write, so the delta + // is reachable through the dirty gate. + setupListRacks([createRack(1n, "Alpha", 7n, 42n), createRack(4n, "Gamma", 0n)]); const onConfirm = vi.fn(); renderModal({ initialSelectedRackIds: [1n, 3n], onConfirm }); - await waitFor(() => expect(screen.getByText("Alpha")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Gamma")).toBeInTheDocument()); + + await userEvent.click(rowCheckbox(1)); // Gamma await userEvent.click(screen.getByTestId("manage-racks-modal-confirm")); const delta = onConfirm.mock.calls[0][0]; + expect(delta.added.map((a: { rackId: bigint }) => a.rackId)).toEqual([4n]); expect(delta.removed).toEqual([]); - expect(delta.added).toEqual([]); + }); + + it("keeps Save disabled when nothing was touched", async () => { + // A loaded, untouched picker has no membership change to write. + const onConfirm = vi.fn(); + renderModal({ initialSelectedRackIds: [1n], onConfirm }); + await waitFor(() => expect(screen.getByText("Alpha")).toBeInTheDocument()); + + await waitFor(() => expect(screen.getByTestId("manage-racks-modal-confirm")).toBeDisabled()); + await userEvent.click(screen.getByTestId("manage-racks-modal-confirm")); + expect(onConfirm).not.toHaveBeenCalled(); }); }); @@ -602,7 +620,10 @@ describe("ManageRacksModal review fixes (#789)", () => { req.onFinally?.(); }); const onConfirm = vi.fn(); - renderModal({ onConfirm }); + // Seeded with Alpha so Select none leaves a real change (removed: [1n]) — + // otherwise the dirty gate would hold Save shut and mask whether the + // select-all guard released it. + renderModal({ initialSelectedRackIds: [1n], onConfirm }); await waitFor(() => expect(screen.getByText("Alpha")).toBeInTheDocument()); await userEvent.click(screen.getByRole("button", { name: "Select all" })); @@ -614,6 +635,7 @@ describe("ManageRacksModal review fixes (#789)", () => { await userEvent.click(screen.getByTestId("manage-racks-modal-confirm")); expect(onConfirm.mock.calls[0][0].added).toEqual([]); + expect(onConfirm.mock.calls[0][0].removed).toEqual([1n]); }); it("disables pagination while a page is loading (no stale-token double advance)", async () => { diff --git a/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.tsx b/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.tsx index b71592fc8c..7b0537fdb9 100644 --- a/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.tsx +++ b/client/src/protoFleet/features/buildings/components/ManageRacksModal/ManageRacksModal.tsx @@ -40,16 +40,20 @@ interface ManageRacksModalProps { // `showSiteFilter: !scope`. allSites: boolean; buildingName: string; - // Rack IDs currently in the building's working set. The modal seeds its - // selection with these so the operator sees the current state and can - // add / remove in one flow. + // Rack IDs currently assigned to the building. The modal seeds its selection + // with these so the operator sees the current state and can add / remove in + // one flow, and diffs against them to gate Save. initialSelectedRackIds: bigint[]; onDismiss: () => void; - // Returns the delta against initialSelectedRackIds. `delta.reassigned` reports - // the added racks that are being reparented so the host can gate the reparent - // confirm before committing. Computed against the items-by-id accumulator - // (every rack seen across pages / select-all), NOT just the current page. + // Save. Returns the delta against initialSelectedRackIds. `delta.reassigned` + // reports the added racks that are being reparented so the host can gate the + // reparent confirm before committing. Computed against the items-by-id + // accumulator (every rack seen across pages / select-all), NOT just the + // current page. This modal owns building membership, so the host persists the + // delta here rather than staging it for a later save. onConfirm: (delta: RackSelectionDelta) => void; + // In-flight signal from the host's write, mirrored into the CTA. + saving?: boolean; } const PAGE_SIZE = 50; @@ -88,6 +92,7 @@ const ManageRacksModal = ({ initialSelectedRackIds, onDismiss, onConfirm, + saving = false, }: ManageRacksModalProps) => { const { listRacks } = useDeviceSets(); const { listBuildingsBySite, listBuildings } = useBuildings(); @@ -562,18 +567,33 @@ const ManageRacksModal = ({ }; }, [showAssigned]); + // The exact membership change Save would write. Derived here so the CTA's + // dirty gate and the write read the same delta — note it isn't a plain + // set-difference (seeded ids the response omitted are excluded on purpose; + // see computeRackSelectionDelta), so comparing selections directly would + // read dirty with nothing to send. + // + // null while the delta isn't computable: during a footer "Select all" fetch + // the selection/accumulator aren't final, so committing would drop the + // pending additions. A placement-facet conflict is a *loaded* empty view (no + // fetch runs, so pageItems stays undefined) — Save must still work there so + // Select-none-then-Save can clear the current racks; the accumulator holds + // the seeds + preserved selections the delta needs. + // + // accumulatorRef mutates in step with pageItems / selectedItems (page loads + // update the former, select-all the latter), so these deps keep it fresh. + const delta = useMemo(() => { + if (selectingAll) return null; + if (pageItems === undefined && !placementFacetConflict) return null; + return computeRackSelectionDelta([...accumulatorRef.current.values()], initialSelectedRackIds, selectedItems); + }, [pageItems, placementFacetConflict, selectingAll, selectedItems, initialSelectedRackIds]); + + const isDirty = !!delta && (delta.added.length > 0 || delta.removed.length > 0); + const handleConfirm = useCallback(() => { - // Guard against confirming mid-load: while a footer "Select all" fetch is in - // flight the selection/accumulator aren't final, so committing would drop the - // pending additions (Continue is also disabled then). A placement-facet - // conflict is a *loaded* empty view (no fetch runs, so pageItems stays - // undefined) — Continue must still work there so Select-none-then-Continue - // can clear the current racks; the accumulator holds the seeds + preserved - // selections needed for the delta. - if (selectingAll) return; - if (pageItems === undefined && !placementFacetConflict) return; - onConfirm(computeRackSelectionDelta([...accumulatorRef.current.values()], initialSelectedRackIds, selectedItems)); - }, [pageItems, placementFacetConflict, selectingAll, selectedItems, initialSelectedRackIds, onConfirm]); + if (!delta) return; + onConfirm(delta); + }, [delta, onConfirm]); // Footer "Select all" (offered only with the toggle off — see below) selects // every ELIGIBLE rack across all pages, not just the visible page. Server @@ -654,15 +674,19 @@ const ManageRacksModal = ({ size="large" className="flex !h-[calc(100dvh-(--spacing(32)))] max-h-[calc(100dvh-(--spacing(32)))] flex-col !overflow-hidden" bodyClassName="flex flex-1 min-h-0 flex-col" - onDismiss={onDismiss} + onDismiss={saving ? undefined : onDismiss} divider={false} testId="manage-racks-modal" buttons={[ { - text: "Continue", + // "Save" because this is where membership is written + // (AssignRacksToBuilding), not a step on the way to a later commit. + text: saving ? "Saving…" : "Save", variant: "primary", onClick: handleConfirm, - disabled: selectingAll, + // Dirty-gated. Also covers the not-computable cases (select-all in + // flight, list not loaded) where `delta` is null. + disabled: saving || !isDirty, dismissModalOnClick: false, testId: "manage-racks-modal-confirm", }, diff --git a/client/src/protoFleet/features/fleetManagement/components/FleetCreateFlow/FleetCreateFlowProvider.tsx b/client/src/protoFleet/features/fleetManagement/components/FleetCreateFlow/FleetCreateFlowProvider.tsx index a85a054f4c..16993a2dfe 100644 --- a/client/src/protoFleet/features/fleetManagement/components/FleetCreateFlow/FleetCreateFlowProvider.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/FleetCreateFlow/FleetCreateFlowProvider.tsx @@ -16,7 +16,9 @@ import BuildingModals from "@/protoFleet/features/buildings/components/BuildingM import BuildingSettingsModal from "@/protoFleet/features/buildings/components/BuildingSettingsModal"; import { useBuildingModals } from "@/protoFleet/features/buildings/hooks/useBuildingModals"; import { ManageRackModal, type RackFormData } from "@/protoFleet/features/fleetManagement/components/ManageRackModal"; +import ReparentWarningDialog from "@/protoFleet/features/fleetManagement/components/ManageRackModal/ReparentWarningDialog"; import RackSettingsModal from "@/protoFleet/features/fleetManagement/components/RackSettingsModal"; +import { useCreateRack } from "@/protoFleet/features/fleetManagement/hooks/useCreateRack"; import SiteModals from "@/protoFleet/features/sites/components/SiteModals"; import SiteSettingsModal from "@/protoFleet/features/sites/components/SiteSettingsModal"; import { useSiteModals } from "@/protoFleet/features/sites/hooks/useSiteModals"; @@ -44,9 +46,10 @@ const MAX_DEVICE_BATCH = 10000; // with the operator's current miner selection, which in all-selection mode // resolves to the full fleet (capped only at MAX_DEVICE_BATCH). Without a // gate here, an oversized seed renders one MinersPane row per miner and -// freezes the browser long before the save-time capacity guard fires. Cap -// at the absolute max before ManageRackModal mounts — the exact -// chosen-capacity check still runs at save once rows×columns are picked. +// freezes the browser long before anything else fires. Cap at the absolute +// max before the operator even reaches Rack Settings — the exact +// chosen-capacity check runs server-side on the create, once rows×columns +// are picked. const MAX_RACK_CAPACITY = 12 * 12; // A seed that moves racked miners into a new building/site sends @@ -110,12 +113,14 @@ const FleetCreateFlowProvider = ({ const activeSite = useFleetStore((state) => state.ui.activeSite); const scopedSiteId = useMemo(() => (activeSite.kind === "site" ? BigInt(activeSite.id) : undefined), [activeSite]); - // Rack create flow. rackSettings drives RackSettingsModal; once the - // operator continues, rackFormData opens ManageRackModal seeded with the - // selected miners. rackSeed survives the settings step so the miners reach - // the manage modal. + // Rack create flow. RackSettingsModal's "Create rack" creates the rack with + // the seeded miners already in it — one atomic SaveRack, so a failed seed + // can't leave an empty rack behind — then ManageRackModal opens on the real + // rack for positioning. rackSeed survives the settings step so the miners + // reach the create call. const [rackSettingsOpen, setRackSettingsOpen] = useState(false); const [rackFormData, setRackFormData] = useState(null); + const [rackId, setRackId] = useState(null); const [rackSeed, setRackSeed] = useState(null); // Holds a seed whose miners have a placement the new rack would clear, // until the operator confirms; null when no confirmation is pending. @@ -124,6 +129,7 @@ const FleetCreateFlowProvider = ({ const openRackSettings = useCallback((seed: RackCreateSeed) => { setRackSeed(seed); setRackFormData(null); + setRackId(null); setRackSettingsOpen(true); }, []); @@ -153,13 +159,35 @@ const FleetCreateFlowProvider = ({ const closeRackFlow = useCallback(() => { setRackSettingsOpen(false); setRackFormData(null); + setRackId(null); setRackSeed(null); }, []); - const handleRackSettingsContinue = useCallback((formData: RackFormData) => { - setRackSettingsOpen(false); - setRackFormData(formData); - }, []); + const { + createRack, + creating: creatingRack, + conflict: rackCreateConflict, + confirmConflict: confirmRackCreateConflict, + cancelConflict: cancelRackCreateConflict, + } = useCreateRack({ + onCreated: (createdId, formData) => { + // The rack and its seeded miners are live. Pulse the lists now — the + // operator may well dismiss the manage step without positioning anything, + // and the rack still exists. + bumpEntities(); + setRackSettingsOpen(false); + setRackSeed(null); + setRackFormData(formData); + setRackId(createdId); + }, + }); + + // The seed's miners ride on the create itself, so membership and the rack + // land in one transaction. + const handleRackSettingsSubmit = useCallback( + (formData: RackFormData) => createRack(formData, rackSeed?.minerIds), + [createRack, rackSeed], + ); const handleRackSaved = useCallback(() => { bumpEntities(); @@ -380,18 +408,30 @@ const FleetCreateFlowProvider = ({ existingRacks={[]} defaultSiteId={scopedSiteId} onDismiss={closeRackFlow} - onContinue={handleRackSettingsContinue} + onSubmit={handleRackSettingsSubmit} + saving={creatingRack} /> ) : null} - {rackFormData ? ( + {rackFormData && rackId !== null ? ( + ) : null} + {rackCreateConflict ? ( + ) : null} {rackConflictSeed ? ( diff --git a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.test.tsx index 8d08ef7571..969a871a59 100644 --- a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.test.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.test.tsx @@ -20,6 +20,10 @@ vi.mock("@/protoFleet/components/MinerSelectionList", () => ({ useEffect(() => { propsRef.current = props; latestProps.current = props; + // The real list pushes its selection up on mount and on every change, + // which is what feeds the Save dirty gate. + const { selectedItems, allSelected, totalMiners } = mockGetSelection(); + props.onSelectionChange?.({ selectedItems, allSelected, totalMiners }); }); useImperativeHandle(ref, () => ({ getSelection: mockGetSelection, @@ -34,7 +38,7 @@ vi.mock("@/shared/components/Modal", () => ({
{children} {buttons?.map((btn: any, i: number) => ( - ))} @@ -103,7 +107,7 @@ describe("ManageMinersModal", () => { expect(latestProps.current.eligibility).toEqual({ rackId: 5n, siteId: 2n, buildingId: 3n }); }); - it("calls onConfirm with selected IDs on continue", () => { + it("calls onConfirm with selected IDs on save", () => { const onConfirm = vi.fn(); mockGetSelection.mockReturnValue({ selectedItems: ["miner-1", "miner-2"], @@ -114,7 +118,7 @@ describe("ManageMinersModal", () => { }); render(); - fireEvent.click(screen.getByText(/Continue/)); + fireEvent.click(screen.getByText(/Save/)); expect(onConfirm).toHaveBeenCalledWith(["miner-1", "miner-2"], false, undefined, []); }); @@ -129,7 +133,7 @@ describe("ManageMinersModal", () => { }); render(); - fireEvent.click(screen.getByText(/Continue/)); + fireEvent.click(screen.getByText(/Save/)); expect(screen.getByText(/Cannot add 3 miners with only 2 available slots/)).toBeInTheDocument(); }); @@ -145,12 +149,12 @@ describe("ManageMinersModal", () => { }); render(); - fireEvent.click(screen.getByText(/Continue/)); + fireEvent.click(screen.getByText(/Save/)); expect(onConfirm).not.toHaveBeenCalled(); }); - it("blocks Continue and prompts to clear the filter when a placement facet conflicts", () => { + it("blocks Save and prompts to clear the filter when a placement facet conflicts", () => { const onConfirm = vi.fn(); mockGetSelection.mockReturnValue({ selectedItems: ["m1", "m2"], @@ -161,10 +165,41 @@ describe("ManageMinersModal", () => { }); render(); - fireEvent.click(screen.getByText(/Continue/)); + fireEvent.click(screen.getByText(/Save/)); // No save (which would otherwise resolve/commit a hidden selection). expect(onConfirm).not.toHaveBeenCalled(); expect(screen.getByText(/Clear the Building or Rack filter/i)).toBeInTheDocument(); }); + + it("disables Save until the selection differs from the rack's members", () => { + mockGetSelection.mockReturnValue({ + selectedItems: ["miner-1"], + allSelected: false, + totalMiners: 10, + reassignedItems: [], + blockedByFilter: false, + }); + + const { unmount } = render(); + expect(screen.getByText(/Save/)).toBeDisabled(); + unmount(); + + // Same count, different miner — still a real membership change. + render(); + expect(screen.getByText(/Save/)).toBeEnabled(); + }); + + it("treats select-all as a change, since it resolves server-side", () => { + mockGetSelection.mockReturnValue({ + selectedItems: ["miner-1"], + allSelected: true, + totalMiners: 10, + reassignedItems: [], + blockedByFilter: false, + }); + + render(); + expect(screen.getByText(/Save/)).toBeEnabled(); + }); }); diff --git a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.tsx b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.tsx index bc9de097dd..d147844b75 100644 --- a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageMinersModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { type MinerListFilter } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; import MinerSelectionList, { @@ -12,6 +12,11 @@ import { Alert } from "@/shared/assets/icons"; import Callout from "@/shared/components/Callout"; import Modal from "@/shared/components/Modal"; +interface SelectionSnapshot { + selectedItems: string[]; + allSelected: boolean; +} + interface ManageMinersModalProps { show: boolean; currentRackMiners: string[]; @@ -38,6 +43,8 @@ interface ManageMinersModalProps { filter: MinerListFilter | undefined, reassignedItems: string[], ) => Promise; + // In-flight signal from the host's write, mirrored into the CTA. + saving?: boolean; } export default function ManageMinersModal({ @@ -49,11 +56,44 @@ export default function ManageMinersModal({ scope, onDismiss, onConfirm, + saving = false, }: ManageMinersModalProps) { const selectionRef = useRef(null); const [overflowError, setOverflowError] = useState(""); + // Mirrors the list's selection so Save can be gated on a real change. The + // list reports its seeded selection on mount, so this is populated before the + // operator touches anything. + const [selection, setSelection] = useState<{ selectedItems: string[]; allSelected: boolean } | null>(null); + + // Keeps the previous object when the selection is equivalent, so React bails + // out of the re-render. Without that, the list re-reports on every render (its + // notify effect depends on the array's identity) and we'd loop forever. + const handleSelectionChange = useCallback(({ selectedItems, allSelected }: SelectionSnapshot) => { + setSelection((prev) => { + if ( + prev && + prev.allSelected === allSelected && + prev.selectedItems.length === selectedItems.length && + prev.selectedItems.every((id, i) => id === selectedItems[i]) + ) { + return prev; + } + return { selectedItems, allSelected }; + }); + }, []); + + // Save writes the membership delta, so it's disabled when the selection still + // matches what's in the rack. "Select all" resolves server-side, so it can't + // be compared here and always counts as a change. + const isDirty = useMemo(() => { + if (!selection) return false; + if (selection.allSelected) return true; + if (selection.selectedItems.length !== currentRackMiners.length) return true; + const current = new Set(currentRackMiners); + return selection.selectedItems.some((id) => !current.has(id)); + }, [selection, currentRackMiners]); - const handleContinue = useCallback(async () => { + const handleSave = useCallback(async () => { const selection = selectionRef.current?.getSelection(); if (!selection) return; @@ -96,9 +136,12 @@ export default function ManageMinersModal({ divider={false} buttons={[ { - text: "Continue", + // Names the write it makes: this picker owns rack membership. + text: saving ? "Saving..." : "Save", variant: "primary", - onClick: handleContinue, + disabled: saving || !isDirty, + loading: saving, + onClick: handleSave, dismissModalOnClick: false, }, ]} @@ -124,6 +167,7 @@ export default function ManageMinersModal({ }} scope={scope} initialSelectedItems={currentRackMiners} + onSelectionChange={handleSelectionChange} eligibility={eligibility} targetRackLabel={targetRackLabel} pairingStatuses={FLEET_VISIBLE_PAIRING_STATUSES} diff --git a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.test.tsx b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.test.tsx index 42ef922885..96624ef4d2 100644 --- a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.test.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.test.tsx @@ -1,14 +1,28 @@ import { MemoryRouter } from "react-router-dom"; -import { fireEvent, render, screen, within } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import ManageRackModal from "./ManageRackModal"; import { RackCoolingType, RackOrderIndex } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; const mockSaveRack = vi.fn(); -const mockGetRackSlots = vi.fn(); -const mockListGroupMembers = vi.fn(); +const mockUpdateRack = vi.fn(); +const mockGetDeviceSet = vi.fn(); +// Membership commits and the placement Save both go through this. Succeeds by +// default; individual tests re-stub it to inspect or fail a call. +interface AssignCall { + targetRackId?: bigint; + deviceIdentifiers: string[]; + slotAssignments?: { deviceIdentifier: string; position?: { row: number; column: number } }[]; + onSuccess?: () => void; + onError?: (message: string) => void; +} +const mockAssignDevicesToRack = vi.fn((args: AssignCall) => args.onSuccess?.()); +// The rack always exists when this modal opens, so its membership comes from the +// server. Resolve both loads immediately with one already-placed miner. +const mockGetRackSlots = vi.fn(({ onSuccess }: { onSuccess: (slots: unknown[]) => void }) => onSuccess([])); +const mockListGroupMembers = vi.fn(({ onSuccess }: { onSuccess: (ids: string[]) => void }) => onSuccess(["miner-1"])); const mockBlinkLED = vi.fn(); const miners: Record = { @@ -33,6 +47,9 @@ vi.mock("@/protoFleet/components/PageHeader/SitePicker", async (importActual) => vi.mock("@/protoFleet/api/useDeviceSets", () => ({ useDeviceSets: () => ({ saveRack: mockSaveRack, + assignDevicesToRack: mockAssignDevicesToRack, + updateRack: mockUpdateRack, + getDeviceSet: mockGetDeviceSet, getRackSlots: mockGetRackSlots, listGroupMembers: mockListGroupMembers, }), @@ -58,9 +75,19 @@ vi.mock("@/shared/hooks/useWindowDimensions", () => ({ vi.mock("@/protoFleet/components/FullScreenTwoPaneModal", () => ({ __esModule: true, - default: ({ open, primaryPane, secondaryPane }: any) => + default: ({ open, buttons, primaryPane, secondaryPane }: any) => open ? (
+ {buttons?.map((button: any) => ( + + ))}
{primaryPane}
{secondaryPane}
@@ -77,8 +104,8 @@ const defaultProps = { orderIndex: RackOrderIndex.BOTTOM_LEFT, coolingType: RackCoolingType.AIR, }, + existingRackId: 7n, existingRacks: [], - seededMinerIds: ["miner-1"], onDismiss: vi.fn(), onSave: vi.fn(), }; @@ -91,6 +118,13 @@ const renderManageRackModal = () => ); describe("ManageRackModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetRackSlots.mockImplementation(({ onSuccess }) => onSuccess([])); + mockListGroupMembers.mockImplementation(({ onSuccess }) => onSuccess(["miner-1"])); + mockAssignDevicesToRack.mockImplementation((args) => args.onSuccess?.()); + }); + it("clears a selected slot when the slot actions sheet is dismissed", () => { renderManageRackModal(); @@ -112,4 +146,51 @@ describe("ManageRackModal", () => { expect(screen.getByText("Position 01")).toBeInTheDocument(); expect(screen.getByTestId("rack-slot-01")).toHaveAttribute("data-slot-state", "assigned"); }); + + it("disables Save until a miner's slot actually changes", () => { + renderManageRackModal(); + + // The rack loaded with one unplaced miner, so there is no placement delta. + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + + fireEvent.click(screen.getByTestId("rack-slot-01")); + fireEvent.click(within(screen.getByTestId("rack-slot-actions-sheet-content")).getByText("Select from list")); + fireEvent.click(screen.getByText("Miner 1")); + + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("Save sends only the miners whose slot changed, with the new position", async () => { + renderManageRackModal(); + + fireEvent.click(screen.getByTestId("rack-slot-01")); + fireEvent.click(within(screen.getByTestId("rack-slot-actions-sheet-content")).getByText("Select from list")); + fireEvent.click(screen.getByText("Miner 1")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockAssignDevicesToRack).toHaveBeenCalledTimes(1)); + const request = mockAssignDevicesToRack.mock.calls[0][0]; + expect(request.targetRackId).toBe(7n); + expect(request.deviceIdentifiers).toEqual(["miner-1"]); + expect(request.slotAssignments).toEqual([ + expect.objectContaining({ + deviceIdentifier: "miner-1", + position: expect.objectContaining({ row: 0, column: 0 }), + }), + ]); + }); + + it("keeps a miner in the list when its removal fails to persist", async () => { + mockAssignDevicesToRack.mockImplementation((args) => args.onError?.("rack is locked")); + renderManageRackModal(); + + fireEvent.click(screen.getByRole("button", { name: "Actions for Miner 1" })); + fireEvent.click(screen.getByText("Remove miner")); + + // The unassign was attempted (no target rack) and refused, so the row stays. + await waitFor(() => expect(mockAssignDevicesToRack).toHaveBeenCalledTimes(1)); + expect(mockAssignDevicesToRack.mock.calls[0][0].targetRackId).toBeUndefined(); + expect(mockAssignDevicesToRack.mock.calls[0][0].deviceIdentifiers).toEqual(["miner-1"]); + expect(screen.getByText("Miner 1")).toBeInTheDocument(); + }); }); diff --git a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.tsx b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.tsx index 0573f98d3d..ac76a0d354 100644 --- a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/ManageRackModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; import { fetchAllSelectableMinerIds } from "./fetchAllSelectableMinerIds"; import ManageMinersModal from "./ManageMinersModal"; @@ -9,7 +10,12 @@ import ScanMinerQrModal, { type ScanAssignmentResult } from "./ScanMinerQrModal" import SearchMinersModal from "./SearchMinersModal"; import { type AssignmentMode, orderIndexToOrigin, originLabel, type RackFormData, type SelectedSlot } from "./types"; import { useRackMinerScope } from "./useRackMinerScope"; -import { type DeviceSet, type RackSlot } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { + type DeviceSet, + type RackSlot, + RackSlotPositionSchema, + RackSlotSchema, +} from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; import { type MinerListFilter, type MinerStateSnapshot, @@ -54,23 +60,19 @@ function filterAssignmentsByValues(record: Record, keepSet: Set< interface ManageRackModalProps { show: boolean; rackSettings: RackFormData; - existingRackId?: bigint; + // The rack always exists by the time this modal opens: Rack Settings creates + // it on its CTA, so there is no staged-create mode to support. + existingRackId: bigint; existingRacks: DeviceSet[]; - // Pre-seeds the new rack's miner list (e.g. from a bulk "Add to rack → - // New rack" flow) so the selected miners land in the left pane ready - // for slot assignment. Ignored in edit mode (existingRackId set). - seededMinerIds?: string[]; // Page-header site scope (single-site only). Forwarded to the embedded - // RackSettingsModal so a new rack created within a site scope keeps its Site - // field locked to that scope. Ignored for an existing rack (edit). + // RackSettingsModal. scopedSiteId?: bigint; onDismiss: () => void; onSave: () => void; - // Fired after the Rack Settings "Continue" persists an EXISTING rack's - // settings (label/placement/zone/dims) — which happens before the final - // miner Save. Parents should refetch in the background so the rack list / - // overview stays consistent even if the operator dismisses the modal - // without pressing Save. No-op for a new rack (nothing is persisted yet). + // Fired after a write lands that the host's own list/overview reflects — the + // Rack Settings save, and each membership commit. Parents should refetch in + // the background so the rack list stays consistent even if the operator + // dismisses without pressing the final placement Save. onSettingsPersisted?: () => void; onDelete?: () => Promise | void; } @@ -80,14 +82,13 @@ export default function ManageRackModal({ rackSettings: initialRackSettings, existingRackId, existingRacks, - seededMinerIds, scopedSiteId, onDismiss, onSave, onSettingsPersisted, onDelete, }: ManageRackModalProps) { - const { saveRack, updateRack, getDeviceSet, getRackSlots, listGroupMembers } = useDeviceSets(); + const { assignDevicesToRack, updateRack, getRackSlots, listGroupMembers } = useDeviceSets(); // Rack placement (site/building) is a site:manage action, enforced server- // side on SaveRack and UpdateDeviceSet. A rack:manage-only operator edits // rack contents and metadata (label/zone/dims) without touching placement, @@ -109,12 +110,11 @@ export default function ManageRackModal({ // Target-rack placement for the selection modals' eligibility filter. // rackSettings always reflects the rack's LIVE persisted placement: a - // Site/Building change in Rack Settings is persisted immediately on Continue + // Site/Building change in Rack Settings is persisted immediately on Save // (handleRackSettingsUpdate), which also cascades the rack's members to the // new placement. So by the time this filter runs, the rack and its members // are already at the placement in rackSettings — no current-vs-pending split, - // and a miner already at the new destination reads as assignable. A new rack - // has no persisted placement, so rackSettings is the intended placement. + // and a miner already at the new destination reads as assignable. const eligibility = useMemo( () => ({ rackId: existingRackId, @@ -124,10 +124,10 @@ export default function ManageRackModal({ [existingRackId, rackSettings.siteId, rackSettings.buildingId], ); - // Core assignment state. A new rack (no existingRackId) can be seeded - // with miners from a bulk "Add to rack → New rack" flow; edit mode - // ignores the seed and loads the rack's real membership below. - const [rackMiners, setRackMiners] = useState(() => (existingRackId ? [] : (seededMinerIds ?? []))); + // Core assignment state, loaded from the rack's real membership below. A + // bulk "Add to rack → New rack" flow seeds its miners onto the create call + // itself, so they arrive here as persisted members like any others. + const [rackMiners, setRackMiners] = useState([]); const [slotAssignments, setSlotAssignments] = useState>({}); const [assignmentMode, setAssignmentMode] = useState("manual"); const [manualAssignmentCache, setManualAssignmentCache] = useState>({}); @@ -146,25 +146,41 @@ export default function ManageRackModal({ const [showScanQr, setShowScanQr] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const scanUndoRef = useRef<(() => void) | null>(null); - - // Pending reparent confirmation. Set when a confirm action would pull miners - // out of a rack/building/site they're currently assigned to; `onConfirm` - // runs the deferred action once the operator accepts the warning (#672). - const [reparentConfirm, setReparentConfirm] = useState<{ count: number; onConfirm: () => void } | null>(null); + const scanUndoRef = useRef<(() => Promise) | null>(null); + + // Pending reparent confirmation, from either of two sources: the picker + // reporting miners currently placed elsewhere (#672), and the server refusing + // to strip a miner's site for a site-less rack. `onConfirm` runs the deferred + // action; `onCancel` lets the server-conflict path report the refusal back to + // the write that is awaiting an answer. + const [reparentConfirm, setReparentConfirm] = useState<{ + count: number; + onConfirm: () => void; + onCancel?: () => void; + } | null>(null); + + // Placement as loaded from the server, keyed by miner: a "row-col" cell or + // "unplaced". The Save delta is this compared against the working set, so a + // miner whose cell never moved is never sent. Membership commits keep it in + // step, entering newcomers as "unplaced" so joining a rack doesn't read as + // pending placement dirt on the Save gate. + const [initialPlacement, setInitialPlacement] = useState>(new Map()); // Loading / error state - const [isLoading, setIsLoading] = useState(!!existingRackId); + const [isLoading, setIsLoading] = useState(true); const [loadFailed, setLoadFailed] = useState(false); const [isSaving, setIsSaving] = useState(false); + // `isSaving` lags a render behind, so the ref is what actually keeps two + // writes — a Save and a picker's membership commit — from overlapping. + const savingRef = useRef(false); + // How many miners the server refused to strip, handed from the failed attempt + // to the confirmation prompt. A ref, not state: nothing renders from it — the + // dialog reads the count off reparentConfirm. + const pendingConflictCountRef = useRef(0); const [errorMsg, setErrorMsg] = useState(""); - // No longer need initial state snapshots — saveRack replaces membership atomically. - - // Fetch existing data for edit mode + // Load the rack's members and slots. useEffect(() => { - if (!existingRackId) return; - let cancelled = false; let loadedMembers = false; let loadedSlots = false; @@ -183,6 +199,10 @@ export default function ManageRackModal({ } setSlotAssignments(assignments); setManualAssignmentCache(assignments); + + const placed = new Map(); + for (const [key, deviceId] of Object.entries(assignments)) placed.set(deviceId, key); + setInitialPlacement(new Map(members.map((id) => [id, placed.get(id) ?? "unplaced"]))); setIsLoading(false); }; @@ -400,15 +420,124 @@ export default function ManageRackModal({ setReparentConfirm({ count, onConfirm: proceed }); }, []); - // SearchMinersModal confirm — add miner to rack and assign to selected slot. - // The modal reports the reassignment flag from the row it selected (exact even - // for fleets larger than the display page). + // Same dialog, awaited: the server rejects assigning a placed miner into a + // site-less rack and writes nothing, so the retry-with-force has to wait for + // an answer inside the write it interrupted. + const confirmSiteStrip = useCallback( + (count: number) => + new Promise((resolve) => { + setReparentConfirm({ + count, + onConfirm: () => resolve(true), + onCancel: () => resolve(false), + }); + }), + [], + ); + + // One AssignDevicesToRack. Unset targetRackId unassigns; set assigns, and + // `slots` rides along in the same transaction. Resolves "conflict" when the + // server refused a site strip (nothing was written). + const dispatchAssign = useCallback( + (deviceIdentifiers: string[], targetRackId: bigint | undefined, force: boolean, slots?: RackSlot[]) => + new Promise<"ok" | "conflict">((resolve, reject) => { + void assignDevicesToRack({ + targetRackId, + deviceIdentifiers, + slotAssignments: slots, + forceClearConflictingSite: force, + onSuccess: () => resolve("ok"), + onConflicts: (conflicts) => { + pendingConflictCountRef.current = conflicts.length; + resolve("conflict"); + }, + onError: (msg) => reject(new Error(msg)), + }); + }), + [assignDevicesToRack], + ); + + // Runs a write, and on a site-strip refusal asks the operator and retries with + // the strip forced. Shared by the membership commits and the placement Save so + // the confirmation behaves identically wherever the refusal surfaces. + const dispatchWithSiteStripConfirm = useCallback( + async (deviceIdentifiers: string[], targetRackId: bigint | undefined, slots?: RackSlot[]): Promise => { + if ((await dispatchAssign(deviceIdentifiers, targetRackId, false, slots)) === "ok") return true; + if (!(await confirmSiteStrip(pendingConflictCountRef.current))) return false; + return (await dispatchAssign(deviceIdentifiers, targetRackId, true, slots)) === "ok"; + }, + [dispatchAssign, confirmSiteStrip], + ); + + // Membership commit. The miner pickers own rack membership, so a confirmed + // selection is written straight away and only placement stays staged for Save. + // Newcomers go in without a slot and land in the load-time snapshot as + // "unplaced", so freshly-committed membership doesn't read as pending + // placement dirt on the Save gate. + // + // The caller owns the working-set update — each entry point merges rows + // differently — and should skip it when this returns false. + const commitMembership = useCallback( + async (added: string[], removed: string[]): Promise => { + if (savingRef.current) return false; + const currentIds = new Set(rackMiners); + const newcomers = added.filter((id) => !currentIds.has(id)); + const leavers = removed.filter((id) => currentIds.has(id)); + // Nothing to write. The caller may still have a placement change to stage + // (e.g. re-placing a miner that's already a member). + if (newcomers.length === 0 && leavers.length === 0) return true; + + // Capacity guard — mirrors the server's. Membership is what fills a rack, + // so this is the check that has to happen here rather than on Save. + const nextCount = rackMiners.length + newcomers.length - leavers.length; + if (totalSlots > 0 && nextCount > totalSlots) { + setErrorMsg( + `Cannot hold ${nextCount} miners with only ${totalSlots} available slots. Deselect some miners or update your rack settings.`, + ); + return false; + } + + savingRef.current = true; + setErrorMsg(""); + setIsSaving(true); + try { + // Removals first: they free their slots before any newcomer lands. An + // unassign has no target rack to strip a site for, so its conflict + // branch never fires — it goes through the same helper for uniformity. + if (leavers.length > 0 && !(await dispatchWithSiteStripConfirm(leavers, undefined))) return false; + if (newcomers.length > 0 && !(await dispatchWithSiteStripConfirm(newcomers, existingRackId))) return false; + } catch (err) { + setErrorMsg(getErrorMessage(err, "Failed to update the rack's miners. Please try again.")); + return false; + } finally { + savingRef.current = false; + setIsSaving(false); + } + + setInitialPlacement((prev) => { + const next = new Map(prev); + for (const id of leavers) next.delete(id); + for (const id of newcomers) next.set(id, "unplaced"); + return next; + }); + // Member counts changed on the host's rack list. + onSettingsPersisted?.(); + return true; + }, + [rackMiners, totalSlots, existingRackId, dispatchWithSiteStripConfirm, onSettingsPersisted], + ); + + // SearchMinersModal confirm — commit the miner into the rack if it isn't a + // member yet, then stage it at the selected slot. The modal reports the + // reassignment flag from the row it selected (exact even for fleets larger + // than the display page). const handleSearchMinerConfirm = useCallback( (minerId: string, isReassignment: boolean) => { if (!selectedSlot) return; const slotKey = selectedSlot.key; - promptReparent(isReassignment ? 1 : 0, () => { - // Add miner to rack if not already present + const apply = async () => { + // Failure leaves the picker open, with the error behind it, to retry. + if (!(await commitMembership([minerId], []))) return; setRackMiners((prev) => (prev.includes(minerId) ? prev : [...prev, minerId])); // Remove any existing assignment for this miner, then assign to selected slot setSlotAssignments((prev) => { @@ -418,16 +547,21 @@ export default function ManageRackModal({ }); setSelectedSlot(null); setShowSearchMiners(false); - }); + }; + promptReparent(isReassignment ? 1 : 0, () => void apply()); }, - [selectedSlot, promptReparent], + [selectedSlot, promptReparent, commitMembership], ); const handleScanMinerAssign = useCallback( - (minerId: string): ScanAssignmentResult | null => { + async (minerId: string): Promise => { if (!selectedSlot) return null; - const previousRackMiners = rackMiners; + const wasMember = rackMiners.includes(minerId); + // Membership commits before the modal reports the assignment, so the + // "assigned" screen never claims a miner that isn't actually in the rack. + if (!(await commitMembership([minerId], []))) return null; + const previousSlotAssignments = slotAssignments; const assignedSlot = selectedSlot; const nextSlotAssignments = removeAssignmentByValue(slotAssignments, minerId); @@ -436,8 +570,12 @@ export default function ManageRackModal({ setRackMiners((prev) => (prev.includes(minerId) ? prev : [...prev, minerId])); setSlotAssignments(nextSlotAssignments); - scanUndoRef.current = () => { - setRackMiners(previousRackMiners); + // Undo has to reverse the write too, not just the staged cell — but only + // for a miner this scan actually added. One that was already a member + // keeps its membership and just loses the new cell. + scanUndoRef.current = async () => { + if (!wasMember && !(await commitMembership([], [minerId]))) return; + if (!wasMember) setRackMiners((prev) => prev.filter((id) => id !== minerId)); setSlotAssignments(previousSlotAssignments); setSelectedSlot(assignedSlot); }; @@ -447,7 +585,7 @@ export default function ManageRackModal({ hasNextSlot: !!getNextAssignableSlot(assignedSlot, nextSlotAssignments), }; }, - [getNextAssignableSlot, getSlotLabel, rackMiners, selectedSlot, slotAssignments], + [getNextAssignableSlot, getSlotLabel, rackMiners, selectedSlot, slotAssignments, commitMembership], ); // Scanned miners already assigned elsewhere use the same reparent warning as @@ -457,7 +595,8 @@ export default function ManageRackModal({ (minerId: string, isReassignment: boolean) => { if (!selectedSlot) return; const slotKey = selectedSlot.key; - promptReparent(isReassignment ? 1 : 0, () => { + const apply = async () => { + if (!(await commitMembership([minerId], []))) return; setRackMiners((prev) => (prev.includes(minerId) ? prev : [...prev, minerId])); setSlotAssignments((prev) => { const next = removeAssignmentByValue(prev, minerId); @@ -467,13 +606,14 @@ export default function ManageRackModal({ setSelectedSlot(null); setShowScanQr(false); scanUndoRef.current = null; - }); + }; + promptReparent(isReassignment ? 1 : 0, () => void apply()); }, - [selectedSlot, promptReparent], + [selectedSlot, promptReparent, commitMembership], ); - const handleScanAssignmentUndo = useCallback(() => { - scanUndoRef.current?.(); + const handleScanAssignmentUndo = useCallback(async () => { + await scanUndoRef.current?.(); scanUndoRef.current = null; }, []); @@ -515,15 +655,18 @@ export default function ManageRackModal({ setSelectedMinerId(null); }, []); - // Remove miner from rack + // Row-level "Remove from rack" — an immediate unassign (the miner keeps + // existing; it just leaves the rack). The row drops only once the write lands, + // so a failure leaves the list truthful. const handleRemoveMiner = useCallback( - (deviceId: string) => { + async (deviceId: string) => { + if (!(await commitMembership([], [deviceId]))) return; setRackMiners((prev) => prev.filter((id) => id !== deviceId)); setSlotAssignments((prev) => removeAssignmentByValue(prev, deviceId)); setManualAssignmentCache((prev) => removeAssignmentByValue(prev, deviceId)); if (selectedMinerId === deviceId) setSelectedMinerId(null); }, - [selectedMinerId], + [selectedMinerId, commitMembership], ); // Unassign miner from slot (keep in rack) @@ -573,244 +716,164 @@ export default function ManageRackModal({ return `Cannot add ${finalIds.length} miners with only ${totalSlots} available slots. Deselect some miners or update your rack settings.`; } - promptReparent(reassignedCount, () => { + // The picker owns membership, so the delta is written here rather than + // staged. Accepting the reparent warning is what authorizes that write. + const keepSet = new Set(finalIds); + const removed = rackMiners.filter((id) => !keepSet.has(id)); + const apply = async () => { + // Failure leaves the picker open with the selection intact to retry. + if (!(await commitMembership(finalIds, removed))) return; setRackMiners(finalIds); setShowManageMiners(false); // Remove assignments for miners no longer in rack - const keepSet = new Set(finalIds); setSlotAssignments((prev) => filterAssignmentsByValues(prev, keepSet)); setManualAssignmentCache((prev) => filterAssignmentsByValues(prev, keepSet)); - }); + }; + promptReparent(reassignedCount, () => void apply()); return undefined; }, - [eligibility, totalSlots, promptReparent], + [eligibility, totalSlots, rackMiners, promptReparent, commitMembership], ); - // RackSettingsModal "Continue" handler. For an EXISTING rack, Continue is - // the settings save: it persists label/zone/dimensions AND placement in a - // single atomic UpdateDeviceSet, then cascades the rack's CURRENT server - // members to the new placement — all server-side, in one transaction. This - // is why the eligibility filter above can trust rackSettings as the rack's - // live placement: by the time the operator opens Manage Miners, the rack and - // its members are already there. Membership is untouched here — "Continue - // saves settings, Save saves miners" — so the modal's draft rackMiners can't - // leak into a settings-only change. - // - // A NEW rack doesn't exist yet, so there's nothing to persist; its settings - // (including placement) ride on the create in handleSave. + // Rack Settings "Save" handler: persists label/zone/dimensions AND placement + // in a single atomic UpdateDeviceSet, then cascades the rack's CURRENT server + // members to the new placement — all server-side, in one transaction. This is + // why the eligibility filter above can trust rackSettings as the rack's live + // placement: by the time the operator opens Manage Miners, the rack and its + // members are already there. Membership is untouched here, so the modal's + // draft rackMiners can't leak into a settings-only change. const handleRackSettingsUpdate = useCallback( async (formData: RackFormData) => { - const rackId = existingRackId; - if (rackId !== undefined) { - // Only send placement when the operator actually changed site/building - // this edit (compared to what the form was seeded with). A metadata-only - // edit (label/zone/dims) omits placement, so UpdateDeviceSet preserves - // the rack's CURRENT server placement — a stale cached value can't - // re-parent a rack that another session moved while this modal was open. - // Zone stays authoritative even with placement omitted (the settings - // path treats an empty zone as an explicit clear). - const placementChanged = - canManagePlacement && - (formData.siteId !== rackSettings.siteId || formData.buildingId !== rackSettings.buildingId); - let updated: DeviceSet | undefined; - try { - await new Promise((resolve, reject) => { - updateRack({ - deviceSetId: rackId, - label: formData.label, - zone: formData.zone, - rows: formData.rows, - columns: formData.columns, - orderIndex: formData.orderIndex, - coolingType: formData.coolingType, - // Unset level -> 0n unassign when the operator did change placement. - siteId: placementChanged ? (formData.siteId ?? 0n) : undefined, - buildingId: placementChanged ? (formData.buildingId ?? 0n) : undefined, - onSuccess: (ds) => { - updated = ds; - resolve(); - }, - onError: (msg) => reject(new Error(msg)), - }); - }); - } catch (err) { - pushToast({ - message: getErrorMessage(err, "Failed to update rack settings. Please try again."), - status: STATUSES.error, + // Only send placement when the operator actually changed site/building + // this edit (compared to what the form was seeded with). A metadata-only + // edit (label/zone/dims) omits placement, so UpdateDeviceSet preserves + // the rack's CURRENT server placement — a stale cached value can't + // re-parent a rack that another session moved while this modal was open. + // Zone stays authoritative even with placement omitted (the settings + // path treats an empty zone as an explicit clear). + const placementChanged = + canManagePlacement && + (formData.siteId !== rackSettings.siteId || formData.buildingId !== rackSettings.buildingId); + let updated: DeviceSet | undefined; + try { + await new Promise((resolve, reject) => { + updateRack({ + deviceSetId: existingRackId, + label: formData.label, + zone: formData.zone, + rows: formData.rows, + columns: formData.columns, + orderIndex: formData.orderIndex, + coolingType: formData.coolingType, + // Unset level -> 0n unassign when the operator did change placement. + siteId: placementChanged ? (formData.siteId ?? 0n) : undefined, + buildingId: placementChanged ? (formData.buildingId ?? 0n) : undefined, + onSuccess: (ds) => { + updated = ds; + resolve(); + }, + onError: (msg) => reject(new Error(msg)), }); - // Keep Rack Settings open (don't apply) so the operator can retry. - return; - } - // Adopt the server's AUTHORITATIVE placement from the response, not the - // submitted formData: when placement was omitted (metadata-only edit) - // the server kept whatever the rack's current site/building is — which - // may differ from the stale formData values if another session moved it. - // The eligibility filter reads rackSettings placement, so trusting the - // response keeps the miner list scoped to where the rack really is. - const serverRackInfo = updated?.typeDetails.case === "rackInfo" ? updated.typeDetails.value : undefined; - const applied: RackFormData = serverRackInfo - ? { - ...formData, - siteId: serverRackInfo.siteId, - buildingId: serverRackInfo.buildingId, - zone: serverRackInfo.zone, - } - : formData; - // Settings are now live on the server. Let the parent refetch so its - // rack list/overview reflects the new label/placement even if the - // operator dismisses without pressing the final miner Save. - onSettingsPersisted?.(); - setRackSettings(applied); - setShowRackSettings(false); + }); + } catch (err) { + pushToast({ + message: getErrorMessage(err, "Failed to update rack settings. Please try again."), + status: STATUSES.error, + }); + // Keep Rack Settings open (don't apply) so the operator can retry. return; } - - setRackSettings(formData); + // Adopt the server's AUTHORITATIVE placement from the response, not the + // submitted formData: when placement was omitted (metadata-only edit) + // the server kept whatever the rack's current site/building is — which + // may differ from the stale formData values if another session moved it. + // The eligibility filter reads rackSettings placement, so trusting the + // response keeps the miner list scoped to where the rack really is. + const serverRackInfo = updated?.typeDetails.case === "rackInfo" ? updated.typeDetails.value : undefined; + const applied: RackFormData = serverRackInfo + ? { + ...formData, + siteId: serverRackInfo.siteId, + buildingId: serverRackInfo.buildingId, + zone: serverRackInfo.zone, + } + : formData; + // Settings are now live on the server. Let the parent refetch so its + // rack list/overview reflects the new label/placement even if the + // operator dismisses without pressing the final miner Save. + onSettingsPersisted?.(); + pushToast({ message: `Rack "${applied.label}" saved`, status: STATUSES.success }); + setRackSettings(applied); setShowRackSettings(false); }, [existingRackId, canManagePlacement, rackSettings, updateRack, onSettingsPersisted], ); - // Save handler — single atomic RPC - const handleSave = useCallback(async () => { - // Capacity guard. handleManageMinersConfirm enforces this when miners are - // added through the sub-modal, but a seeded new rack (bulk "New rack") - // populates rackMiners directly and bypasses that path — saveRack accepts - // members beyond the slot count, so an over-fill would persist silently. - if (rackMiners.length > totalSlots) { - setErrorMsg( - `Cannot add ${rackMiners.length} miners with only ${totalSlots} available slots. Deselect some miners or update your rack settings.`, + // One RackSlot per miner whose cell differs from what loaded: position set to + // the new cell, position omitted to clear it. Miners the operator never moved + // aren't named at all, so a concurrent placement change elsewhere in the rack + // survives this save — the whole reason Save no longer goes through SaveRack, + // which replaced the rack's entire member set from a possibly-stale snapshot. + const placementDelta = useMemo(() => { + const cellByMiner = new Map(); + for (const [key, deviceId] of Object.entries(activeAssignments)) cellByMiner.set(deviceId, key); + + const delta: RackSlot[] = []; + for (const deviceId of rackMiners) { + const before = initialPlacement.get(deviceId) ?? "unplaced"; + const after = cellByMiner.get(deviceId) ?? "unplaced"; + if (before === after) continue; + if (after === "unplaced") { + delta.push(create(RackSlotSchema, { deviceIdentifier: deviceId })); + continue; + } + const [row, column] = after.split("-").map(Number); + delta.push( + create(RackSlotSchema, { + deviceIdentifier: deviceId, + position: create(RackSlotPositionSchema, { row, column }), + }), ); + } + return delta; + }, [activeAssignments, rackMiners, initialPlacement]); + + // Save owns slot placement only. Membership commits in the pickers, and + // label/zone/dimensions/site/building commit in Rack Settings — so by the time + // the operator gets here the only unwritten thing left is where each miner + // sits in the grid. + const handleSave = useCallback(async () => { + if (savingRef.current) return; + // Defensive: the CTA is dirty-gated, so a clean save shouldn't be reachable. + // Close rather than write nothing and toast as though something changed. + if (placementDelta.length === 0) { + onSave(); return; } + savingRef.current = true; setIsSaving(true); setErrorMsg(""); - try { - // Build slot assignments from the active assignments map - const slotAssignmentsList = Object.entries(activeAssignments).map(([key, deviceId]) => { - const [row, col] = key.split("-").map(Number); - return { deviceIdentifier: deviceId, row, column: col }; - }); - - // Placement rides on CREATE only. An existing rack's Site/Building (and - // zone/dimensions) are already persisted on the Rack Settings "Continue" - // — Continue saves settings, Save saves miners — so an edit Save omits - // placement: it preserves the rack's current server placement and can't - // clobber a move made by another session while this modal was open. - const sendPlacement = canManagePlacement && existingRackId === undefined; - - // Existing-rack Save is miners-only, but SaveRack always rewrites - // rack_info, so re-send the rack's CURRENT server metadata rather than the - // modal's cached copy — otherwise a concurrent zone/dimension edit from - // another session would be reverted (and stale dims could mis-validate the - // new slots). Best-effort: fall back to the cached values on a fetch miss - // so a transient error can't block the miner save. - let meta = { - label: rackSettings.label, - zone: rackSettings.zone, - rows: rackSettings.rows, - columns: rackSettings.columns, - orderIndex: rackSettings.orderIndex, - coolingType: rackSettings.coolingType, - }; - if (existingRackId !== undefined) { - await new Promise((resolve) => { - getDeviceSet({ - deviceSetId: existingRackId, - onSuccess: (ds) => { - if (ds.typeDetails.case === "rackInfo") { - const ri = ds.typeDetails.value; - meta = { - label: ds.label, - zone: ri.zone, - rows: ri.rows, - columns: ri.columns, - orderIndex: ri.orderIndex, - coolingType: ri.coolingType, - }; - } - resolve(); - }, - onNotFound: () => resolve(), - onError: () => resolve(), - }); - }); - } - - const finishSuccess = () => { - pushToast({ - message: existingRackId ? `Rack "${meta.label}" updated` : `Rack "${meta.label}" created`, - status: STATUSES.success, - }); - onSave(); - }; - - // Persist the miners. When the rack is site-less, the server rejects a - // member that currently has a site/building (it would be stripped) and - // returns a conflict list without writing — mirroring the reparent RPC. - // We surface the same ReparentWarningDialog and, on confirm, retry with - // forceClearConflictingSite so the strip is explicit, not silent. - const runSaveRack = (force: boolean): Promise<"ok" | "conflict"> => - new Promise((resolve, reject) => { - saveRack({ - deviceSetId: existingRackId, - label: meta.label, - zone: meta.zone, - rows: meta.rows, - columns: meta.columns, - orderIndex: meta.orderIndex, - coolingType: meta.coolingType, - deviceIdentifiers: rackMiners, - slotAssignments: slotAssignmentsList, - // Create sends its chosen placement (unset level → NULL), gated on - // site:manage. Edit omits placement (persisted on Continue). - siteId: sendPlacement ? (rackSettings.siteId ?? 0n) : undefined, - buildingId: sendPlacement ? (rackSettings.buildingId ?? 0n) : undefined, - forceClearConflictingSite: force, - onSuccess: () => resolve("ok"), - onConflicts: (conflicts) => { - setReparentConfirm({ - count: conflicts.length, - onConfirm: () => { - setReparentConfirm(null); - setIsSaving(true); - setErrorMsg(""); - runSaveRack(true) - .then((outcome) => { - if (outcome === "ok") finishSuccess(); - }) - .catch((err) => setErrorMsg(getErrorMessage(err, "Failed to save. Please try again."))) - .finally(() => setIsSaving(false)); - }, - }); - resolve("conflict"); - }, - onError: (msg) => reject(new Error(msg)), - }); - }); - - // conflict → the dialog above drives the confirm/force retry; don't toast - // success or close the modal until that resolves. - if ((await runSaveRack(false)) === "ok") finishSuccess(); + // Every named miner is already a member; re-asserting membership is a + // no-op server-side (and is what lets the slots ride the same + // transaction), so this cannot move a miner between racks. + const ok = await dispatchWithSiteStripConfirm( + placementDelta.map((slot) => slot.deviceIdentifier), + existingRackId, + placementDelta, + ); + if (!ok) return; + pushToast({ message: `Miner positions saved for "${rackSettings.label}"`, status: STATUSES.success }); + onSave(); } catch (err) { setErrorMsg(getErrorMessage(err, "Failed to save. Please try again.")); } finally { + savingRef.current = false; setIsSaving(false); } - }, [ - existingRackId, - rackSettings, - rackMiners, - totalSlots, - activeAssignments, - canManagePlacement, - getDeviceSet, - saveRack, - onSave, - ]); + }, [placementDelta, existingRackId, rackSettings.label, dispatchWithSiteStripConfirm, onSave]); if (!show) return null; @@ -842,9 +905,11 @@ export default function ManageRackModal({ onClick: () => setShowManageMiners(true), }, { + // Placement is all that's left to write, so the gate is the exact + // delta the request carries — no diff, nothing to save. text: isSaving ? "Saving..." : "Save", variant: variants.primary, - disabled: isSaving || isLoading || loadFailed, + disabled: isSaving || isLoading || loadFailed || placementDelta.length === 0, loading: isSaving, onClick: handleSave, }, @@ -917,10 +982,10 @@ export default function ManageRackModal({ show={showRackSettings} existingRacks={existingRacks} initialFormData={rackSettings} - existingRack={existingRackId !== undefined} + existingRack defaultSiteId={scopedSiteId} onDismiss={() => setShowRackSettings(false)} - onContinue={handleRackSettingsUpdate} + onSubmit={handleRackSettingsUpdate} /> ) : null} @@ -932,6 +997,7 @@ export default function ManageRackModal({ targetRackLabel={rackSettings.label} maxSlots={totalSlots} scope={scope} + saving={isSaving} onDismiss={() => setShowManageMiners(false)} onConfirm={handleManageMinersConfirm} /> @@ -973,7 +1039,11 @@ export default function ManageRackModal({ setReparentConfirm(null)} + onCancel={() => { + const abandon = reparentConfirm.onCancel; + setReparentConfirm(null); + abandon?.(); + }} onConfirm={() => { const proceed = reparentConfirm.onConfirm; setReparentConfirm(null); diff --git a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/MinersPane.tsx b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/MinersPane.tsx index 18edd7eeed..7e159c8b7a 100644 --- a/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/MinersPane.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/ManageRackModal/MinersPane.tsx @@ -68,6 +68,9 @@ function MinerRow({ onBlinkLED: (deviceId: string) => void; }) { const name = miner?.name; + // Miners outside the first display page have no cached snapshot, so fall back + // to the identifier rather than labelling a control "for undefined". + const label = name || deviceId; const ipAddress = miner?.ipAddress; const macAddress = miner?.macAddress; const model = miner?.model; @@ -161,6 +164,7 @@ function MinerRow({ {isAssigned ? (
) : (
- {errorMsg ? } title={errorMsg} /> : null} - Promise; + creating: boolean; + /** + * Non-null while a site-strip confirmation is pending, carrying what the + * warning dialog needs: how many seeded miners would be displaced, and the + * label of the rack they'd move into. + */ + conflict: { count: number; rackLabel: string } | null; + confirmConflict: () => void; + cancelConflict: () => void; +} + +export function useCreateRack({ + // Receives the submitted form data alongside the new id: the caller opens + // ManageRackModal on it, and the forced-retry path fires this from the + // confirmation dialog, where the form data is no longer in scope. + onCreated, +}: { + onCreated: (rackId: bigint, formData: RackFormData) => void; +}): UseCreateRackResult { + const { saveRack } = useDeviceSets(); + const [creating, setCreating] = useState(false); + // The `creating` state lags a render behind the click, so the ref is what + // actually blocks a double-click from dispatching two creates. + const creatingRef = useRef(false); + const [conflict, setConflict] = useState<{ count: number; rackLabel: string } | null>(null); + // Holds the inputs for the forced retry while the confirmation is showing. + const pendingRef = useRef<{ formData: RackFormData; seededMinerIds: string[] } | null>(null); + + const dispatch = useCallback( + (formData: RackFormData, seededMinerIds: string[], force: boolean): Promise => { + if (creatingRef.current) return Promise.resolve(undefined); + creatingRef.current = true; + setCreating(true); + return new Promise((resolve) => { + saveRack({ + label: formData.label, + zone: formData.zone, + rows: formData.rows, + columns: formData.columns, + orderIndex: formData.orderIndex, + coolingType: formData.coolingType, + deviceIdentifiers: seededMinerIds, + // Slots are the operator's next step, in the manage modal. Seeded + // miners land as members without a position. + slotAssignments: [], + // A new rack has no prior placement to preserve, so send the chosen + // one explicitly — including "unassigned", which the form encodes as + // undefined and the wire as 0. + siteId: formData.siteId ?? 0n, + buildingId: formData.buildingId ?? 0n, + forceClearConflictingSite: force, + onSuccess: (deviceSet) => { + pushToast({ message: `Rack "${formData.label}" created`, status: STATUSES.success }); + setConflict(null); + pendingRef.current = null; + onCreated(deviceSet.id, formData); + resolve(deviceSet.id); + }, + // Seeded miners that currently have a site/building the new rack + // lacks would be stripped. The server wrote nothing; hold the inputs + // so the caller's confirmation can retry with force. + onConflicts: (conflicts: PerDeviceRackConflict[]) => { + pendingRef.current = { formData, seededMinerIds }; + setConflict({ count: conflicts.length, rackLabel: formData.label }); + resolve(undefined); + }, + onError: (message) => { + pushToast({ message: message || "Failed to create rack. Please try again.", status: STATUSES.error }); + resolve(undefined); + }, + onFinally: () => { + creatingRef.current = false; + setCreating(false); + }, + }); + }); + }, + [saveRack, onCreated], + ); + + const createRack = useCallback( + (formData: RackFormData, seededMinerIds?: string[]) => dispatch(formData, seededMinerIds ?? [], false), + [dispatch], + ); + + const confirmConflict = useCallback(() => { + const pending = pendingRef.current; + if (!pending) return; + setConflict(null); + void dispatch(pending.formData, pending.seededMinerIds, true); + }, [dispatch]); + + const cancelConflict = useCallback(() => { + pendingRef.current = null; + setConflict(null); + }, []); + + return { createRack, creating, conflict, confirmConflict, cancelConflict }; +} diff --git a/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx b/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx index 960f825718..3ed7897642 100644 --- a/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx +++ b/client/src/protoFleet/features/fleetManagement/pages/RackOverviewPage.tsx @@ -579,9 +579,9 @@ const RackOverviewPage = () => { resolveRack(rack.id); void refetchStats(); }} - // Settings "Continue" persists before the final Save; refresh the - // overview in the background (modal stays open) so a later dismiss - // can't leave stale label/placement on screen. + // The Rack settings Save and the miner pickers each persist on their + // own; refresh the overview in the background (modal stays open) so a + // later dismiss can't leave stale label/placement/members on screen. onSettingsPersisted={() => { resolveRack(rack.id); void refetchStats(); diff --git a/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx b/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx index 2be4287c45..7efdb1d88d 100644 --- a/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx +++ b/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx @@ -31,8 +31,10 @@ import FleetGroupActionsMenu from "@/protoFleet/features/fleetManagement/compone import FleetGroupListActionBar from "@/protoFleet/features/fleetManagement/components/FleetGroupActionsMenu/FleetGroupListActionBar"; import { useOptionalFleetOutletContext } from "@/protoFleet/features/fleetManagement/components/FleetLayout"; import { ManageRackModal, type RackFormData } from "@/protoFleet/features/fleetManagement/components/ManageRackModal"; +import ReparentWarningDialog from "@/protoFleet/features/fleetManagement/components/ManageRackModal/ReparentWarningDialog"; import { RackCard } from "@/protoFleet/features/fleetManagement/components/RackCard"; import RackSettingsModal from "@/protoFleet/features/fleetManagement/components/RackSettingsModal"; +import { useCreateRack } from "@/protoFleet/features/fleetManagement/hooks/useCreateRack"; import { BUILDING_URL_PARAM } from "@/protoFleet/features/fleetManagement/utils/buildingFilterUrl"; import { FILTER_URL_PARAM_KEYS, @@ -874,12 +876,6 @@ const RacksPage = () => { return ; }, [hasActiveFilters, isLoading, totalCount, handleClearFilters]); - const handleRackSettingsContinue = useCallback((formData: RackFormData) => { - setShowRackSettingsModal(false); - setManageRackFormData(formData); - setManageRackId(undefined); - }, []); - const handleManageRackDismiss = useCallback(() => { setManageRackFormData(null); setManageRackId(undefined); @@ -891,14 +887,33 @@ const RacksPage = () => { resetAndFetch(); fetchZones(); }, [resetAndFetch, fetchZones]); - // Rack Settings "Continue" persists an existing rack's settings before the - // final miner Save, so refresh the list in the background (without closing - // the modal) to keep it consistent if the operator then dismisses. + // Rack Settings "Save" persists an existing rack's settings before the final + // miner Save, so refresh the list in the background (without closing the + // modal) to keep it consistent if the operator then dismisses. const handleRackSettingsPersisted = useCallback(() => { resetAndFetch(); fetchZones(); }, [resetAndFetch, fetchZones]); + // "Create rack" in Rack Settings creates it for real, then hands off to + // ManageRackModal for miners and placement. The rack exists from here on, so + // the list refreshes immediately rather than waiting for a later Save. + const { + createRack, + creating: creatingRack, + conflict: rackCreateConflict, + confirmConflict, + cancelConflict, + } = useCreateRack({ + onCreated: (rackId, formData) => { + setShowRackSettingsModal(false); + setManageRackFormData(formData); + setManageRackId(rackId); + resetAndFetch(); + fetchZones(); + }, + }); + const handleDeleteRack = useCallback(() => { if (!manageRackId) return Promise.resolve(); return new Promise((resolve, reject) => { @@ -1134,12 +1149,13 @@ const RacksPage = () => { existingRacks={racks} defaultSiteId={scopedSiteId} onDismiss={() => setShowRackSettingsModal(false)} - onContinue={handleRackSettingsContinue} + onSubmit={createRack} + saving={creatingRack} /> ) : null} - {manageRackFormData ? ( + {manageRackFormData && manageRackId !== undefined ? ( { onDismiss={handleManageRackDismiss} onSave={handleManageRackSave} onSettingsPersisted={handleRackSettingsPersisted} - onDelete={manageRackId ? handleDeleteRack : undefined} + onDelete={handleDeleteRack} + /> + ) : null} + {rackCreateConflict ? ( + ) : null} @@ -1461,12 +1485,13 @@ const RacksPage = () => { existingRacks={racks} defaultSiteId={scopedSiteId} onDismiss={() => setShowRackSettingsModal(false)} - onContinue={handleRackSettingsContinue} + onSubmit={createRack} + saving={creatingRack} /> ) : null} - {manageRackFormData ? ( + {manageRackFormData && manageRackId !== undefined ? ( { onSettingsPersisted={handleRackSettingsPersisted} /> ) : null} + {rackCreateConflict ? ( + + ) : null} {reparentTarget ? ( ({ + listAllBuildingsMock: vi.fn(), + listSitesMock: vi.fn(), +})); + +vi.mock("@/protoFleet/api/buildings", () => ({ + useBuildings: () => ({ listAllBuildings: listAllBuildingsMock }), +})); + +vi.mock("@/protoFleet/api/sites", () => ({ + useSites: () => ({ listSites: listSitesMock }), +})); + +// The picker self-fetches the org-wide building list plus the site catalog for +// its Site column; both mocks resolve synchronously through onSuccess. +const seed = (rows: { id: bigint; name: string; siteId: bigint }[]) => { + listAllBuildingsMock.mockImplementation((args?: { onSuccess?: (rows: unknown[]) => void }) => { + args?.onSuccess?.( + rows.map((r) => + create(BuildingWithCountsSchema, { + building: create(BuildingSchema, { id: r.id, name: r.name, siteId: r.siteId }), + rackCount: 0n, + }), + ), + ); + return Promise.resolve(undefined); + }); + listSitesMock.mockImplementation((args?: { onSuccess?: (rows: unknown[]) => void }) => { + args?.onSuccess?.([create(SiteWithCountsSchema, { site: create(SiteSchema, { id: 7n, name: "East DC" }) })]); + return Promise.resolve(undefined); + }); +}; + +const baseProps = { + open: true as const, + siteId: 7n, + initialSelectedBuildingIds: [] as bigint[], + onDismiss: () => undefined, +}; + +describe("ManageBuildingsModal — New building hand-off", () => { + beforeEach(() => { + listAllBuildingsMock.mockReset(); + listSitesMock.mockReset(); + }); + + it("omits the New building button when no launch handler is supplied", async () => { + seed([{ id: 1n, name: "Building A", siteId: 0n }]); + render(); + + await waitFor(() => expect(screen.getByTestId("manage-buildings-modal-confirm")).toBeInTheDocument()); + expect(screen.queryByTestId("manage-buildings-modal-create-new")).not.toBeInTheDocument(); + }); + + it("abandons the pending selection when launching create — Save is what commits", async () => { + seed([{ id: 1n, name: "Building A", siteId: 0n }]); + const onConfirm = vi.fn(); + const onCreateNewLaunch = vi.fn(); + render(); + + await waitFor(() => expect(screen.getByTestId("manage-buildings-modal-create-new")).toBeEnabled()); + + // Check the unassigned building, then hand off to the create flow without + // pressing Save. + fireEvent.click(screen.getAllByRole("checkbox")[0]); + fireEvent.click(screen.getByTestId("manage-buildings-modal-create-new")); + + // Nothing is written — leaving without Save means the selection never + // landed, which is what makes the abandon safe to reason about. + expect(onConfirm).not.toHaveBeenCalled(); + expect(onCreateNewLaunch).toHaveBeenCalled(); + }); +}); + +describe("ManageBuildingsModal — Save gate", () => { + beforeEach(() => { + listAllBuildingsMock.mockReset(); + listSitesMock.mockReset(); + }); + + it("disables Save until the selection differs from the site's current membership", async () => { + seed([{ id: 1n, name: "Building A", siteId: 0n }]); + const onConfirm = vi.fn(); + render(); + + const save = await screen.findByTestId("manage-buildings-modal-confirm"); + // Loaded with nothing checked and nothing seeded — no membership change to + // write, so Save must not fire an AssignBuildingsToSite no-op. + await waitFor(() => expect(save).toBeDisabled()); + + fireEvent.click(screen.getAllByRole("checkbox")[0]); + await waitFor(() => expect(save).toBeEnabled()); + + fireEvent.click(save); + expect(onConfirm).toHaveBeenCalledWith({ + added: [{ buildingId: 1n, label: "Building A" }], + removed: [], + }); + }); + + it("keeps Save disabled while the building list is still loading", () => { + // No onSuccess → items stays undefined, so the delta can't be computed. + listAllBuildingsMock.mockReturnValue(Promise.resolve(undefined)); + listSitesMock.mockReturnValue(Promise.resolve(undefined)); + render(); + + expect(screen.getByTestId("manage-buildings-modal-confirm")).toBeDisabled(); + }); + + it("re-checking a seeded building leaves Save disabled", async () => { + // Uncheck then re-check: the delta returns to empty, so the gate closes + // again rather than latching open on "the operator touched something". + seed([{ id: 1n, name: "Building A", siteId: 7n }]); + render(); + + const save = await screen.findByTestId("manage-buildings-modal-confirm"); + await waitFor(() => expect(save).toBeDisabled()); + + const checkbox = screen.getAllByRole("checkbox")[0]; + fireEvent.click(checkbox); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(checkbox); + await waitFor(() => expect(save).toBeDisabled()); + }); +}); diff --git a/client/src/protoFleet/features/sites/components/ManageBuildingsModal/ManageBuildingsModal.tsx b/client/src/protoFleet/features/sites/components/ManageBuildingsModal/ManageBuildingsModal.tsx index f49fb4469a..2b54a92259 100644 --- a/client/src/protoFleet/features/sites/components/ManageBuildingsModal/ManageBuildingsModal.tsx +++ b/client/src/protoFleet/features/sites/components/ManageBuildingsModal/ManageBuildingsModal.tsx @@ -4,7 +4,7 @@ import { buildBuildingPickerItem, type BuildingPickerItem } from "./buildingPick import { computeBuildingSelectionDelta } from "./buildingSelectionDelta"; import { useBuildings } from "@/protoFleet/api/buildings"; import { useSites } from "@/protoFleet/api/sites"; -import { ChevronDown } from "@/shared/assets/icons"; +import { ChevronDown, Plus } from "@/shared/assets/icons"; import Button, { sizes, variants } from "@/shared/components/Button"; import List from "@/shared/components/List"; import type { ColConfig, ColTitles } from "@/shared/components/List/types"; @@ -17,16 +17,26 @@ interface ManageBuildingsModalProps { open: boolean; // Parent site context drives the eligibility split. siteId: bigint; - // Building IDs currently in the site's working set. The modal seeds its - // selection with these so the operator sees current state and can add / - // remove in one flow. + // Building IDs currently assigned to the site. The modal seeds its selection + // with these so the operator sees current state and can add / remove in one + // flow, and diffs against them to gate Save. initialSelectedBuildingIds: bigint[]; onDismiss: () => void; - // Returns the delta against initialSelectedBuildingIds: `added` is the + // Save. Receives the delta against initialSelectedBuildingIds: `added` is the // newly-checked buildings (id + label so the caller can render without a // separate lookup); `removed` is the seeded ids the operator unchecked. // Untouched buildings are in neither list — the caller leaves them as-is. - onConfirm: (delta: { added: { buildingId: bigint; label: string }[]; removed: bigint[] }) => void; + // This modal owns site membership, so the caller persists the delta here + // rather than staging it for a later save. + onConfirm: (delta: { added: { buildingId: bigint; label: string }[]; removed: bigint[] }) => Promise | void; + // In-flight signal from the caller's write, mirrored into the CTA. + saving?: boolean; + // Renders a "New building" button beside Save that hands off to the full + // building-create flow instead of picking an existing building — mirroring + // ParentPickerModal's createNewLaunch affordance ("New rack"). Leaving this + // way abandons the pending selection: Save is what commits it, so nothing + // was written. Omitted = no create affordance. + onCreateNewLaunch?: () => void; } const PAGE_SIZE = 25; @@ -60,6 +70,8 @@ const ManageBuildingsModal = ({ initialSelectedBuildingIds, onDismiss, onConfirm, + saving = false, + onCreateNewLaunch, }: ManageBuildingsModalProps) => { const { listAllBuildings } = useBuildings(); const { listSites } = useSites(); @@ -139,10 +151,22 @@ const ManageBuildingsModal = ({ const hasPreviousPage = page > 0; const hasNextPage = page < totalPages - 1; + // The exact membership change Save would write. Derived here so the CTA's + // dirty gate and the write read the same delta — note it isn't a plain + // set-difference: seeded ids the picker's response omitted, and rows that + // became ineligible, are excluded on purpose (see computeBuildingSelectionDelta), + // so a raw selection comparison would call the modal dirty when there's + // nothing to send. + const delta = useMemo( + () => (items ? computeBuildingSelectionDelta(items, initialSelectedBuildingIds, selectedItems) : null), + [items, initialSelectedBuildingIds, selectedItems], + ); + const isDirty = !!delta && (delta.added.length > 0 || delta.removed.length > 0); + const handleConfirm = useCallback(() => { - if (!items) return; - onConfirm(computeBuildingSelectionDelta(items, initialSelectedBuildingIds, selectedItems)); - }, [items, selectedItems, initialSelectedBuildingIds, onConfirm]); + if (!delta) return; + void onConfirm(delta); + }, [delta, onConfirm]); const handleSelectAll = useCallback(() => { if (!items) return; @@ -159,14 +183,35 @@ const ManageBuildingsModal = ({ size="large" className="flex !h-[calc(100dvh-(--spacing(32)))] max-h-[calc(100dvh-(--spacing(32)))] flex-col !overflow-hidden" bodyClassName="flex flex-1 min-h-0 flex-col" - onDismiss={onDismiss} + onDismiss={saving ? undefined : onDismiss} divider={false} testId="manage-buildings-modal" buttons={[ + // ButtonGroup sorts the primary button last, so this lands to the left + // of Save. + ...(onCreateNewLaunch + ? [ + { + text: "New building", + variant: variants.secondary, + prefixIcon: , + onClick: onCreateNewLaunch, + disabled: saving, + dismissModalOnClick: false, + testId: "manage-buildings-modal-create-new", + }, + ] + : []), { - text: "Continue", + // "Save" because this is where membership is written + // (AssignBuildingsToSite), not a step on the way to a later commit. + text: saving ? "Saving…" : "Save", variant: "primary", onClick: handleConfirm, + // Dirty-gated, and blocked while `items` is undefined — the delta + // can't be computed without the list, so every selection would read + // clean for the wrong reason. + disabled: saving || !isDirty, dismissModalOnClick: false, testId: "manage-buildings-modal-confirm", }, diff --git a/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.test.tsx b/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.test.tsx index 341dfa9750..33d9b6977c 100644 --- a/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.test.tsx +++ b/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.test.tsx @@ -11,7 +11,10 @@ import { emptySiteFormValues, type SiteFormValues } from "@/protoFleet/api/sites // caller's onSuccess synchronously with whatever rows the test queued. const { listBuildingsBySiteMock } = vi.hoisted(() => ({ listBuildingsBySiteMock: vi.fn() })); -vi.mock("@/protoFleet/api/buildings", () => ({ +// Keep the module's real helpers (emptyBuildingFormValues is used by both this +// modal and the BuildingSettingsModal it renders) and override only the hook. +vi.mock("@/protoFleet/api/buildings", async (importActual) => ({ + ...(await importActual()), useBuildings: () => ({ listBuildingsBySite: listBuildingsBySiteMock, listAllBuildings: vi.fn(), @@ -19,6 +22,17 @@ vi.mock("@/protoFleet/api/buildings", () => ({ }), })); +// Stub the building picker: the real one self-fetches the org-wide building +// list and site catalog, which this suite doesn't wire up. All we need from it +// here is the "New building" hand-off that reaches the inline create flow. +vi.mock("../ManageBuildingsModal", () => ({ + default: ({ onCreateNewLaunch }: { onCreateNewLaunch?: () => void }) => ( + + ), +})); + const seedBuildings = (rows: { id: bigint; name: string; siteId: bigint; rackCount: bigint }[]) => { listBuildingsBySiteMock.mockImplementation((args?: { onSuccess?: (rows: unknown[]) => void }) => { args?.onSuccess?.( @@ -39,113 +53,108 @@ const draft = (overrides: Partial = {}): SiteFormValues => ({ ...overrides, }); +const site7 = create(SiteSchema, { id: 7n, name: "East DC" }); + const noop = () => undefined; +// Common props — the site is always persisted by the time the modal opens. +const baseProps = { + open: true as const, + site: site7, + draft: draft({ name: "East DC" }), + onAssignBuildings: vi.fn().mockResolvedValue(true), + onRemoveBuilding: vi.fn().mockResolvedValue(true), + onCreateBuilding: vi.fn().mockResolvedValue(null), + onEditDetails: noop, + onDeleteRequested: noop, + onDismiss: noop, +}; + describe("ManageSiteModal", () => { beforeEach(() => listBuildingsBySiteMock.mockReset()); - it("invokes onSave and closes when the save reports closeOnSuccess", async () => { - const onSave = vi.fn().mockResolvedValue({ closeOnSuccess: true }); + it("Save writes nothing and just closes (placement has no backend yet)", async () => { + seedBuildings([]); + const onAssignBuildings = vi.fn(); const onDismiss = vi.fn(); - render( - , - ); + render(); fireEvent.click(screen.getByTestId("manage-site-modal-save")); - await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onDismiss).toHaveBeenCalled()); + // Membership commits in the picker, so this CTA has nothing to persist. + expect(onAssignBuildings).not.toHaveBeenCalled(); }); - it("disables Save in edit mode until the building list has loaded", () => { - // No seed → listBuildingsBySite never calls onSuccess, so the working - // set stays in the loading (undefined) state. - const site = create(SiteSchema, { id: 7n, name: "East DC" }); - render( - , - ); + it("disables Save until the building list has loaded", () => { + // No seed → listBuildingsBySite never calls onSuccess, so the list stays + // in the loading (undefined) state. + render(); expect(screen.getByTestId("manage-site-modal-save")).toBeDisabled(); }); it("Site settings fires the parent callback", () => { + seedBuildings([]); const onEditDetails = vi.fn(); - render( - Promise.resolve(null)} - onEditDetails={onEditDetails} - onDeleteRequested={noop} - onDismiss={noop} - />, - ); + render(); fireEvent.click(screen.getAllByTestId("manage-site-modal-edit-details")[0]); expect(onEditDetails).toHaveBeenCalled(); }); it("Delete site fires onDeleteRequested", () => { + seedBuildings([]); const onDeleteRequested = vi.fn(); - render( - Promise.resolve(null)} - onEditDetails={noop} - onDeleteRequested={onDeleteRequested} - onDismiss={noop} - />, - ); + render(); fireEvent.click(screen.getAllByTestId("manage-site-modal-delete")[0]); expect(onDeleteRequested).toHaveBeenCalled(); }); - it("create mode lets buildings be staged before the site is saved", () => { - render( - Promise.resolve(null)} - onEditDetails={noop} - onDeleteRequested={noop} - onDismiss={noop} - />, - ); + it("creates a building inline via the picker hand-off and injects it into the working set", async () => { + seedBuildings([]); + const created = create(BuildingSchema, { id: 5n, name: "New Bldg", siteId: 7n }); + const onCreateBuilding = vi.fn().mockResolvedValue(created); + render(); - // No "save first" gate — the empty working set renders the same - // add-buildings affordance as edit mode, and Manage buildings is enabled. - expect(screen.queryByText("Save the site first to add buildings.")).not.toBeInTheDocument(); + // Create is reached through the picker's "New building" hand-off, not a + // dedicated button on this modal. + expect(screen.getByText("No buildings added to this site")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-site-modal-create-building")).not.toBeInTheDocument(); + fireEvent.click(screen.getAllByTestId("manage-site-modal-manage-buildings")[0]); + fireEvent.click(screen.getByTestId("stub-picker-create-new")); + expect(screen.getByTestId("building-settings-modal")).toBeInTheDocument(); + + // Name the building and save. + fireEvent.change(screen.getByTestId("building-settings-name-input"), { target: { value: "New Bldg" } }); + fireEvent.click(screen.getByTestId("building-settings-modal-save")); + + await waitFor(() => expect(onCreateBuilding).toHaveBeenCalled()); + // The created building is injected and the create modal closes. + await waitFor(() => expect(screen.getByTestId("manage-site-modal-building-row-5")).toBeInTheDocument()); + expect(screen.queryByTestId("building-settings-modal")).not.toBeInTheDocument(); + }); + + it("keeps the create modal open and injects nothing when create fails", async () => { + seedBuildings([]); + const onCreateBuilding = vi.fn().mockResolvedValue(null); + render(); + + fireEvent.click(screen.getAllByTestId("manage-site-modal-manage-buildings")[0]); + fireEvent.click(screen.getByTestId("stub-picker-create-new")); + fireEvent.change(screen.getByTestId("building-settings-name-input"), { target: { value: "Nope" } }); + fireEvent.click(screen.getByTestId("building-settings-modal-save")); + + await waitFor(() => expect(onCreateBuilding).toHaveBeenCalled()); + // Modal stays open; no row was added. + expect(screen.getByTestId("building-settings-modal")).toBeInTheDocument(); expect(screen.getByText("No buildings added to this site")).toBeInTheDocument(); - expect(screen.getAllByTestId("manage-site-modal-manage-buildings")[0]).toBeEnabled(); - expect(screen.getAllByTestId("manage-site-modal-empty-state-add")[0]).toBeEnabled(); - // Save is allowed immediately (creates the site with an empty building set). - expect(screen.getByTestId("manage-site-modal-save")).toBeEnabled(); }); it("shows comma-separated meta on each corner of the preview", () => { + seedBuildings([]); const site = create(SiteSchema, { id: 7n, name: "East DC", @@ -155,8 +164,7 @@ describe("ManageSiteModal", () => { }); render( { locationState: "MA", powerCapacityMw: 5, })} - onSave={() => Promise.resolve(null)} - onEditDetails={noop} - onDeleteRequested={noop} - onDismiss={noop} />, ); expect(screen.getByText("East DC, Boston, MA")).toBeInTheDocument(); expect(screen.getByText("5 MW, 0 buildings")).toBeInTheDocument(); }); - it("renders rack count as a subtitle and kebab-removes a building from the working set", () => { + it("renders rack count as a subtitle and kebab-remove unassigns immediately", async () => { seedBuildings([{ id: 1n, name: "Building A", siteId: 7n, rackCount: 3n }]); - const site = create(SiteSchema, { id: 7n, name: "East DC" }); - render( - Promise.resolve(null)} - onEditDetails={noop} - onDeleteRequested={noop} - onDismiss={noop} - />, - ); + const onRemoveBuilding = vi.fn().mockResolvedValue(true); + render(); // Rack count renders as the row subtitle (not a trailing column). expect(screen.getByTestId("manage-site-modal-building-row-1")).toBeInTheDocument(); expect(screen.getByText("3 racks")).toBeInTheDocument(); - // Open the kebab and remove — the row drops from the list locally. fireEvent.click(screen.getByTestId("manage-site-modal-building-menu-1")); fireEvent.click(screen.getByTestId("manage-site-modal-remove-building-1")); - expect(screen.queryByTestId("manage-site-modal-building-row-1")).not.toBeInTheDocument(); + + await waitFor(() => expect(onRemoveBuilding).toHaveBeenCalledWith(1n, "Building A")); + await waitFor(() => expect(screen.queryByTestId("manage-site-modal-building-row-1")).not.toBeInTheDocument()); // Empty state takes over once the last building is removed. expect(screen.getByText("No buildings added to this site")).toBeInTheDocument(); }); + + it("keeps the row when the unassign fails", async () => { + seedBuildings([{ id: 1n, name: "Building A", siteId: 7n, rackCount: 3n }]); + const onRemoveBuilding = vi.fn().mockResolvedValue(false); + render(); + + fireEvent.click(screen.getByTestId("manage-site-modal-building-menu-1")); + fireEvent.click(screen.getByTestId("manage-site-modal-remove-building-1")); + + await waitFor(() => expect(onRemoveBuilding).toHaveBeenCalled()); + expect(screen.getByTestId("manage-site-modal-building-row-1")).toBeInTheDocument(); + }); }); diff --git a/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.tsx b/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.tsx index bb2b956f13..1ae17c65de 100644 --- a/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.tsx +++ b/client/src/protoFleet/features/sites/components/ManageSiteModal/ManageSiteModal.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { create } from "@bufbuild/protobuf"; import ManageBuildingsModal from "../ManageBuildingsModal"; -import { useBuildings } from "@/protoFleet/api/buildings"; -import { type BuildingWithCounts } from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; -import { type Site } from "@/protoFleet/api/generated/sites/v1/sites_pb"; +import { type BuildingFormValues, emptyBuildingFormValues, useBuildings } from "@/protoFleet/api/buildings"; +import { type Building, type BuildingWithCounts } from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; +import { type Site, SiteWithCountsSchema } from "@/protoFleet/api/generated/sites/v1/sites_pb"; import { type SiteFormValues } from "@/protoFleet/api/sites"; import FullScreenTwoPaneModal from "@/protoFleet/components/FullScreenTwoPaneModal"; +import BuildingSettingsModal from "@/protoFleet/features/buildings/components/BuildingSettingsModal"; import { formatSiteAddress } from "@/protoFleet/features/sites/formatAddress"; import { Ellipsis } from "@/shared/assets/icons"; import Button, { sizes, variants } from "@/shared/components/Button"; @@ -13,36 +15,32 @@ import Header from "@/shared/components/Header"; import PlaceholderBlock from "@/shared/components/PlaceholderBlock"; import { useEscapeDismiss } from "@/shared/hooks/useEscapeDismiss"; -export type ManageSiteModalMode = "create" | "edit"; - -// One building in the modal's working set. Seeded from the server fetch and -// mutated locally by the Manage buildings picker; persisted on Save. +// One building shown in the modal's list. Seeded from the server fetch and +// re-synced after each membership write (the picker's Save and the row-level +// Remove both commit immediately, so this list mirrors the server rather than +// staging a pending edit). interface BuildingEntry { buildingId: bigint; label: string; rackCount: bigint; } -// Net membership change between the load-time snapshot and the working set, -// computed on Save and applied by the host via AssignBuildingsToSite. -export interface BuildingMembershipDelta { - added: bigint[]; - removed: bigint[]; -} - interface ManageSiteModalProps { open: boolean; - mode: ManageSiteModalMode; draft: SiteFormValues; - // In edit mode the parent has a Site row to drive the right-pane preview - // header off; in create mode there is no row yet so the preview uses the - // draft values directly. - site?: Site; - // Persisted at save time. In edit mode the delta is applied via - // AssignBuildingsToSite; in create mode the host first creates the site - // (the delta is empty since building management is gated until the site - // exists). Returns whether the modal should close on success. - onSave: (delta: BuildingMembershipDelta) => Promise<{ closeOnSuccess: boolean } | null>; + // The site is always persisted by the time this modal opens (the create + // flow's Continue creates it up front), so it drives the right-pane preview + // header and the site_id for building writes. + site: Site; + // Applies the buildings picker's membership delta via AssignBuildingsToSite. + // Resolves true on success; a false result leaves the picker open to retry. + onAssignBuildings: (delta: { added: bigint[]; removed: bigint[] }) => Promise; + // Row-level "Remove building" — unassigns it from this site immediately. + onRemoveBuilding: (buildingId: bigint, label: string) => Promise; + // Creates a new building against this site and returns the created row (or + // null on failure). The building is associated to the site atomically, so + // the modal injects the returned row into its working set without a refetch. + onCreateBuilding: (values: BuildingFormValues) => Promise; // Opens SiteSettingsModal stacked on top to edit name / address / etc. onEditDetails: () => void; // Opens the cascade delete dialog (edit) or discards the pending create. @@ -77,15 +75,15 @@ const BuildingRow = ({ label: string; rackCount: bigint; saving: boolean; - onRemove: (buildingId: bigint) => void; + onRemove: (buildingId: bigint, label: string) => void; }) => { const [showMenu, setShowMenu] = useState(false); const menuRef = useRef(null); const handleRemove = useCallback(() => { setShowMenu(false); - onRemove(buildingId); - }, [buildingId, onRemove]); + onRemove(buildingId, label); + }, [buildingId, label, onRemove]); useEscapeDismiss(showMenu ? () => setShowMenu(false) : undefined); @@ -146,10 +144,11 @@ const BuildingRow = ({ const ManageSiteModal = ({ open, - mode, draft, site, - onSave, + onAssignBuildings, + onRemoveBuilding, + onCreateBuilding, onEditDetails, onDeleteRequested, onDismiss, @@ -159,22 +158,16 @@ const ManageSiteModal = ({ unassignedMinerCount, }: ManageSiteModalProps) => { const { listBuildingsBySite } = useBuildings(); - // undefined = loading; [] = loaded-empty. Working set the operator edits - // via the picker before Save. + // undefined = loading; [] = loaded-empty. Mirrors the site's committed + // membership — every mutation in this modal writes before updating it. const [entries, setEntries] = useState(undefined); const [showManageBuildings, setShowManageBuildings] = useState(false); + const [showCreateBuilding, setShowCreateBuilding] = useState(false); - // Snapshot of the building ids present at load time so Save can diff the - // working set into add / remove buckets for AssignBuildingsToSite. - const initialIdsRef = useRef>(new Set()); - - // Only fetch when edit mode has a persisted site; create mode renders an - // empty working set until the first Save lands a row. Skipping the effect - // for the no-fetch branches keeps the setState-in-effect lint clean. - const shouldFetchBuildings = open && mode === "edit" && site !== undefined; - const fetchSiteId = shouldFetchBuildings ? site.id : undefined; + const shouldFetchBuildings = open; + const fetchSiteId = site.id; useEffect(() => { - if (!shouldFetchBuildings || fetchSiteId === undefined) return; + if (!shouldFetchBuildings) return; const controller = new AbortController(); void listBuildingsBySite({ siteId: fetchSiteId, @@ -188,45 +181,41 @@ const ManageSiteModal = ({ rackCount: r.rackCount, })); setEntries(seeded); - initialIdsRef.current = new Set(seeded.map((e) => e.buildingId.toString())); }, onError: () => { setEntries([]); - initialIdsRef.current = new Set(); }, }); return () => controller.abort(); }, [shouldFetchBuildings, fetchSiteId, listBuildingsBySite, buildingsRefreshKey]); - // Create mode never fetches (there's no persisted site yet) but still keeps - // a working set: buildings the operator stages via the picker before the - // first Save, which the host then assigns to the freshly-created site. - // `entries` starts undefined there, so fall back to an empty (loaded, not - // loading) list rather than the edit-mode loading skeleton. - const displayEntries: BuildingEntry[] | undefined = useMemo( - () => (shouldFetchBuildings ? entries : (entries ?? [])), - [shouldFetchBuildings, entries], - ); const sortedEntries = useMemo( - () => (displayEntries ? [...displayEntries].sort((a, b) => a.label.localeCompare(b.label)) : undefined), - [displayEntries], + () => (entries ? [...entries].sort((a, b) => a.label.localeCompare(b.label)) : undefined), + [entries], ); - const previewTitle = (site?.name || draft.name || "Untitled site").trim(); + const previewTitle = (site.name || draft.name || "Untitled site").trim(); const previewLocation = useMemo(() => formatSiteAddress(draft) || "—", [draft]); const previewCapacity = draft.powerCapacityMw > 0 ? `${draft.powerCapacityMw} MW` : "—"; const buildingCount = sortedEntries?.length ?? 0; - const currentBuildingIds = useMemo(() => (displayEntries ?? []).map((e) => e.buildingId), [displayEntries]); + const currentBuildingIds = useMemo(() => (entries ?? []).map((e) => e.buildingId), [entries]); + // The inline building-create dropdown is always locked to this site, so a + // one-element list built from `site` is all BuildingSettingsModal needs — and + // it sidesteps the brief window right after create-on-Continue where the + // page's site catalog hasn't refetched the new row yet. + const buildingCreateSites = useMemo(() => [create(SiteWithCountsSchema, { site })], [site]); - // Picker confirm — apply the delta against the working set. `added` joins - // entries without disturbing existing rows; `removed` drops only those - // entries. Buildings in neither list are untouched, so a seeded building - // the picker's listBuildings response omitted (race / paging gap) is - // preserved. Mirrors ManageBuildingModal.handleManageRacksConfirm. - const handleManageBuildingsConfirm = (delta: { + // Picker Save — the write already happened by the time this resolves, so + // mirror it into the list. `added` joins without disturbing existing rows; + // `removed` drops only those entries. Buildings in neither list are + // untouched, so a member the picker's listBuildings response omitted (race / + // paging gap) is preserved. A failed write leaves the picker open to retry. + const handleManageBuildingsConfirm = async (delta: { added: { buildingId: bigint; label: string }[]; removed: bigint[]; }) => { + const ok = await onAssignBuildings({ added: delta.added.map((a) => a.buildingId), removed: delta.removed }); + if (!ok) return; const removedSet = new Set(delta.removed.map((id) => id.toString())); setEntries((prev) => { const kept = (prev ?? []).filter((e) => !removedSet.has(e.buildingId.toString())); @@ -241,21 +230,31 @@ const ManageSiteModal = ({ setShowManageBuildings(false); }; - // Kebab "Remove building" — drop it from the working set. Persisted on - // Save as a `removed` delta entry, which moves the building to - // "Unassigned" (the building itself is not deleted). - const handleRemoveBuilding = useCallback((buildingId: bigint) => { - setEntries((prev) => (prev ?? []).filter((e) => e.buildingId !== buildingId)); - }, []); + // Kebab "Remove building" — unassigns immediately (moves the building to + // "Unassigned"; the building itself is not deleted). Drop the row only once + // the write lands so a failure leaves the list truthful. + const handleRemoveBuilding = useCallback( + async (buildingId: bigint, label: string) => { + const ok = await onRemoveBuilding(buildingId, label); + if (!ok) return; + setEntries((prev) => (prev ?? []).filter((e) => e.buildingId !== buildingId)); + }, + [onRemoveBuilding], + ); - const handleSave = async () => { - const initial = initialIdsRef.current; - const current = new Set((displayEntries ?? []).map((e) => e.buildingId.toString())); - const added = [...current].filter((id) => !initial.has(id)).map((id) => BigInt(id)); - const removed = [...initial].filter((id) => !current.has(id)).map((id) => BigInt(id)); - const result = await onSave({ added, removed }); - if (!result) return; - if (result.closeOnSuccess) onDismiss(); + // Inline building-create confirm. CreateBuilding already associated the new + // building to this site, so inject it into the list rather than refetching. + // A failed create returns null (toast shown by the host) and leaves the + // create modal open. + const handleCreateBuildingSave = async (values: BuildingFormValues) => { + const created = await onCreateBuilding(values); + if (!created) return; + setEntries((prev) => { + const next = prev ?? []; + if (next.some((e) => e.buildingId === created.id)) return next; + return [...next, { buildingId: created.id, label: created.name, rackCount: 0n }]; + }); + setShowCreateBuilding(false); }; const buildingsBusy = saving || sortedEntries === undefined; @@ -286,20 +285,21 @@ const ManageSiteModal = ({ text: "Manage buildings", variant: variants.secondary, onClick: () => setShowManageBuildings(true), - // Enabled in both modes — create stages buildings into the working - // set and assigns them on the first Save. Only blocked while the - // edit-mode list is still loading (sortedEntries undefined). + // Only blocked while the building list is still loading + // (sortedEntries undefined). disabled: saving || sortedEntries === undefined, testId: "manage-site-modal-manage-buildings", }, { - text: saving ? "Saving…" : "Save", + // Placeholder. Building membership now commits in the picker (and + // row-level Remove commits on click), so this modal owns only + // building placement within the site — which has no backend yet and + // is not tracked by an issue. Until then Save writes nothing and + // just closes; deliberately no success toast, since there's nothing + // to report. + text: "Save", variant: variants.primary, - onClick: handleSave, - // Block Save until the edit-mode building list has loaded. - // handleSave diffs the working set against initialIdsRef; firing - // it while entries are still undefined would diff against an empty - // (or stale) working set and unassign buildings on save. + onClick: onDismiss, disabled: buildingsBusy, testId: "manage-site-modal-save", }, @@ -316,6 +316,8 @@ const ManageSiteModal = ({ ) : sortedEntries.length === 0 ? (
No buildings added to this site + {/* Single affordance — the picker itself carries the + "New building" hand-off for creating one from scratch. */}