Repository navigation
Conversation
The backend already serves POST /api/ri-exchange/azure-instances/ compatible-offerings and /exchange, but the frontend had no client for them. Add typed request/response shapes that mirror internal/api/handler_ri_exchange.go (nullable amounts stay nullable), the two API calls, and pure helpers the exchange UI builds on: the billing subscription a set of source reservations must be exchanged under, form and guardrail validation matching the backend's checks, and user-facing text for the 422 guardrail refusals and other failures. Refs LeanerCloud#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The modal takes the selected source reservations, lets the user set how many of each to hand back and which SKU/region/term/quantity to buy, and previews the exchange through compatible-offerings: net payable, refunds and purchases totals, the compatible offerings and any Azure policy errors. Execute is offered only for a preview with no policy errors and a reported net payable, only to holders of execute:ri-exchange, and only after the user types a maximum payment due and currency (never prefilled from the quote), matching the backend's mandatory guardrail. Any change to sources or targets discards the preview. Execution goes through a destructive confirm and its outcome, including 422 guardrail refusals, is shown below the preview. Refs LeanerCloud#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…exchange modal The Azure reservations table was read-only. For users who can preview purchases it now has a selection column and an "Exchange selected" action that opens the exchange modal for the ticked reservations and reloads the tab after an exchange. The modal module is loaded on demand so the main bundle stays under webpack's 500 KiB size hint. Refs LeanerCloud#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Playwright spec with mocked Azure endpoints: select a reservation, preview, get refused without a spending limit, execute with an explicit cap and currency, and see a 422 guardrail refusal explained rather than reported as a generic error. Refs LeanerCloud#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): REQUEST_CHANGES Blocking:
|
…ot executed" A 5xx, gateway timeout, client timeout or network error on execute does not prove the exchange was refused. The backend runs ExecuteExchange on a detached context and audits those failures as outcome unknown (directExchangeFailureStatus), so labelling them "Exchange not executed" was wrong. It also left Execute enabled on the same preview and skipped the list refresh, which invited a retry of an irreversible exchange. Only a 4xx is now reported as a refusal. Any other failure says the exchange may have been submitted and asks the user to check the history or the Azure portal. It also refreshes the list and history and retires the preview, so a retry needs a fresh preview. Also show a toast when the exchange dialog chunk fails to load, and give the selection column's header a visually hidden accessible name. Refs LeanerCloud#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
On the blocking review finding above: fixed in
Checks at This PR is Azure-only. Azure deploys are paused, so it will be kept merge-ready but held out of the current merge train. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): REQUEST_CHANGES Blocking:
|
…ortal The unknown-outcome text told the user to check the RI exchange history, but that history reads only ri_exchange_history, while direct Azure executions are audited in ri_exchange_direct_executions, which nothing serves. The history could never show the exchange, inviting the user to conclude it did not happen and execute again. Point only at the Azure portal, as the backend's own 500 text does, and say the history here does not list Azure exchanges. Closing the modal while execute was in flight dropped the outcome; it is now shown as a toast (a sticky warning when the outcome is unknown). Execute used the default 90s client timeout against a 4-minute backend execution budget, so slow successful exchanges were reported as unknown; it now gets a 5-minute timeout. Refs LeanerCloud#81 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
On the re-review above: fixed in
Fail-before: 10 of the 68 new or updated tests fail on Checks at
This is still held out of the merge train while Azure deploys are paused. A fresh re-review of |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE Blocking:
|
|
The Azure merge hold on this PR is lifted. The owner released it on 2026-10-20 ("merging is allowed; Azure/GCP apply stays paused", see #1114), and IAmJSD's team had missed that. This PR is frontend-only, so it needs no Azure deploy. It goes back into the normal queue: main merge, fresh CI, review, merge. Infra PRs that wait on the first Azure/GCP deploy are tracked in #1413. |
Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend already serves the Azure RI exchange endpoints (
POST /api/ri-exchange/azure-instances/compatible-offeringsand/exchange), but the frontend never called them: the Azure exchangeable-reservations list on Inventory > RI Exchange was read-only. This PR adds selection on that list and an exchange modal that previews the priced quote, requires an explicit spending limit and currency, and executes the exchange.Closes #81
What changed
0a3e8d7c):frontend/src/api/types.ts,api/riexchange.ts,api/index.tsadd typed request/response shapes mirroringinternal/api/handler_ri_exchange.go(amounts staynumber | null) andgetAzureCompatibleOfferings/executeAzureExchange.ExchangeableAzureRIgainsbilling_scope_id, which the backend already returns. Newfrontend/src/lib/azure-exchange.tsholds pure helpers: the subscription to exchange under (from the sources'billing_scope_id; all sources must share it, as the backend's ownership gate requires), form and guardrail validation matching the backend checks (1-50 items, quantities, P1Y/P3Y/P5Y terms, non-negative plain decimal cap, 3-letter currency), and user-facing error text (422 guardrail refusals get a specific hint for cap exceeded, currency mismatch, missing net payable and Azure policy; 403, 404 and 400 are explained).8d25988d): newfrontend/src/riexchange-azure-modal.ts, built with DOM/textContentonly. "Return" rows set a quantity per selected reservation (max = its quantity); "Buy instead" target rows take VM size, region, term and quantity (seeded from the first source; add/remove targets). Preview exchange shows net payable, refunds total, purchases total, the compatible-offerings table and Azure policy errors. Execute appears only when the preview has no policy errors and a non-null net payable, and only for holders ofexecute:ri-exchange(isRIExchanger()). It requires a typed maximum payment due and currency (never prefilled; the quoted values appear only as placeholders) and goes through a destructiveconfirmDialognaming the sources, targets, quoted net payable and the cap. Editing any source or target discards the preview. The execute outcome is shown below the preview.ad57119d): newfrontend/src/riexchange-azure.tsplus small changes inriexchange.ts. For users withview:purchases, the Azure table gets a checkbox column and an "Exchange selected (N)" button. The modal module is loaded on demand withimport(). The tab reloads after a successful exchange. Styles instyles/components.css.tests-e2e/azure-ri-exchange.spec.ts(4ac6e996).a6b14b3b): only a 4xx from execute is reported as "Exchange not executed" (every backend gate, the guardrails, the idempotency claim and Azure's client-fault rejections answer 4xx). A 5xx, gateway timeout, client timeout (status 0) or network error is reported as "The exchange may have been submitted to Azure. Verify the reservation state in the Azure portal (the reservation order's history) before trying again; the RI exchange history here does not list Azure exchanges." plus the detail. This matches how the backend audits those failures (directExchangeFailureStatusrecords them as unknown). After such a failure the tab reloads the list and history, and the modal hides Execute and drops the preview, so trying again needs a new preview.frontend/src/lib/azure-exchange.ts(isAzureExchangeOutcomeUnknown),riexchange-azure-modal.ts. The same commit shows a toast if the modal chunk fails to load ("Could not load the exchange dialog. Reload the page and try again.") and gives the checkbox column's header a visually hidden "Select for exchange" name (.sr-only).8ffadd67): the unknown-outcome text points only at the Azure portal, matching the backend's own 500 text, because direct Azure executions are audited inri_exchange_direct_executions, which the tab's history (ri_exchange_history) does not read. If the modal is closed while execute is in flight, the outcome is shown as a toast: success, a 4xx refusal as an error, and an unknown outcome as a warning that stays until dismissed.executeAzureExchangegets a 5-minute client timeout (api/riexchange.ts), since the backend's detached execution budget is 4 minutes and the default 90s would report slow successful exchanges as unknown.Why this approach
The flow matches the AWS exchange modal (quote, confirm, execute; quote invalidated on edit; destructive confirm with Cancel focused), but the Azure backend contract differs: targets are free-form SKU/location/term with no offering-ID picker, and the cap and currency are mandatory. So it is a separate module rather than more branches inside the 2,400-line
openExchangeModal.The subscription comes from each reservation's own billing scope rather than the header account chip: the backend refuses any source not billed to
subscription_id, and with "All accounts" selected there is no chip to use. Mixed-subscription selections are refused up front with a clear message. Preview is gated onview:purchasesand Execute onexecute:ri-exchange, the permissions each backend endpoint checks.The modal is lazy-loaded because the eager version pushed
app.jsfrom 497 to 509 KiB, over webpack's 500 KiB size hint. Withimport()the main bundle is 498 KiB plus an 11 KiB chunk.Screenshots
Before is main (
a14dc8f2), after is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API.Verification
Run on this branch's head
8ffadd67on top of maina14dc8f2, fromfrontend/:npm run lint: 0 errors (142 pre-existing warnings). Typecheck passes. Build passes with the one pre-existing entrypoint-size warning (app.js498 KiB vs 497 KiB on main).src/__tests__/azure-exchange.test.ts: billing-scope parsing, mixed subscriptions, form and guardrail validation, null amounts never shown as 0, 422/403/404/400 messages. Execute failures with a 500, a 504, a client timeout (status 0) or a network error each assert the full unknown-outcome message with no "not executed" prefix. A 422 guardrail refusal and a 409 keep their definite wording, and a preview 504 stays "Could not preview".src/__tests__/riexchange-azure.test.ts(23 tests): toolbar selection and count, an error toast when the modal chunk fails to load, mixed-subscription refusal, exact compatible-offerings request body, preview rendering, no Execute on policy errors / null net payable / null preview, preview errors, invalidation on edit, cap and currency required, exact execute body with the cap and an upper-cased currency, cancelled confirm sends nothing, 422 text, a 504 and a client timeout on execute (unknown-outcome text, list/history refreshed, Execute hidden, a further click sends nothing), the outcome shown as a toast when the modal is closed mid-execute (success, 422 refusal, sticky unknown-outcome warning) and no toast while it is open, preview-only withoutexecute:ri-exchange, add/remove targets. Newsrc/__tests__/api-riexchange-azure.test.ts: execute is sent with a 5-minutetimeoutMs, preview keeps the default. Plus one case inriexchange.test.ts(the Azure table shows the selection column for an admin, and its header has the visually hidden name).--randomize.riexchange.test.tsfails 27 tests under--randomizeseed 1922165236, and main's copy of that file fails the same way with the same seed.e2e-local.sh: 159 passed (157 plus 2 new: the full preview, cap required, execute flow with exact request bodies; and a 422 guardrail refusal explained).scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.Notes for review
view:purchases; Execute only forexecute:ri-exchange(RI Exchanger group), with a note explaining that to everyone else. The AWS table still gates its Exchange button onadmin:*, which no longer coversexecute:ri-exchangesince #1644. Aligning the two is a separate decision.Overlaps
No textual conflicts with any other UI branch (checked with
git merge-tree). Branches for #1135, #136, #18, #186, #190, #284, #319, #321, #363, #37, #709, #727, #729, #737, #738, #740 and #751 also editfrontend/src/riexchange.ts, in other functions. Only #1135 and #18 touch Azure-adjacent lines (the reshape empty-state/popover code, notrenderAzureRIsTable).components.cssandapi/types.tsget appends that merge cleanly.Note for the UI owner
This changes UI code that @cristim owns. IAmJSD reviewed the before/after screenshots and asked for it to be merged; any design point you disagree with can be a follow-up.
🤖 Generated with Claude Code