Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/__tests__/riexchange-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ jest.mock('../navigation', () => ({

jest.mock('../state', () => ({
getCurrentUser: jest.fn(),
// loadRIExchange reads the provider/account chips to scope the request
// (issue #871); default to the AWS, all-accounts path used by these tests.
getCurrentProvider: jest.fn(() => 'aws'),
getCurrentAccountIDs: jest.fn(() => []),
}));

import * as api from '../api';
Expand Down
140 changes: 140 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Mock the api module defensively (riexchange.ts imports it)
jest.mock('../api', () => ({
listConvertibleRIs: jest.fn(),
listExchangeableAzureRIs: jest.fn(),
getRIUtilization: jest.fn(),
getReshapeRecommendations: jest.fn(),
getExchangeQuote: jest.fn(),
Expand Down Expand Up @@ -543,6 +544,15 @@ describe('reshape recommendations empty state', () => {
(api.getRIUtilization as jest.Mock).mockResolvedValue([]);
(api.getRIExchangeHistory as jest.Mock).mockResolvedValue([]);
(api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [], recs_staleness: '', recs_collected_at: null });
// resetAllMocks() in afterEach wipes the state mock implementations;
// loadRIExchange reads the chips to scope the request (issue #871), so
// restore the AWS/all-accounts default here.
const stateMod = jest.requireMock('../state') as {
getCurrentProvider: jest.Mock;
getCurrentAccountIDs: jest.Mock;
};
stateMod.getCurrentProvider.mockReturnValue('aws');
stateMod.getCurrentAccountIDs.mockReturnValue([]);
});

afterEach(() => {
Expand Down Expand Up @@ -677,6 +687,8 @@ describe('RI Exchange filter subscriptions (issue #186)', () => {
const stateMod = jest.requireMock('../state') as {
subscribeProvider: jest.Mock;
subscribeAccount: jest.Mock;
getCurrentProvider: jest.Mock;
getCurrentAccountIDs: jest.Mock;
};
stateMod.subscribeProvider.mockImplementation((cb: () => void) => {
_providerListeners.push(cb);
Expand All @@ -686,6 +698,10 @@ describe('RI Exchange filter subscriptions (issue #186)', () => {
_accountListeners.push(cb);
return () => undefined;
});
// loadRIExchange now reads the provider/account chips to scope the
// request (issue #871); restore their implementations too.
stateMod.getCurrentProvider.mockImplementation(() => 'aws');
stateMod.getCurrentAccountIDs.mockImplementation(() => []);
});

afterEach(() => {
Expand Down Expand Up @@ -738,3 +754,127 @@ describe('RI Exchange filter subscriptions (issue #186)', () => {
expect(api.listConvertibleRIs).toHaveBeenCalledTimes(1);
});
});

// issue #871: RI Exchange must honour the Main Header global Provider/Account
// filter, matching the Active Commitments + Coverage sub-tabs (#866/#881).
describe('RI Exchange global filter scoping (issue #871)', () => {
let instancesEl: HTMLDivElement;
let recsEl: HTMLDivElement;
let historyEl: HTMLDivElement;
let riExchangePanel: HTMLDivElement;

const stateMod = (): {
getCurrentProvider: jest.Mock;
getCurrentAccountIDs: jest.Mock;
subscribeProvider: jest.Mock;
subscribeAccount: jest.Mock;
} => jest.requireMock('../state');

beforeEach(() => {
instancesEl = document.createElement('div');
instancesEl.id = 'ri-exchange-instances-list';
recsEl = document.createElement('div');
recsEl.id = 'ri-exchange-recommendations-list';
historyEl = document.createElement('div');
historyEl.id = 'ri-exchange-history-list';
riExchangePanel = document.createElement('div');
riExchangePanel.id = 'inventory-ri-exchange';
document.body.append(instancesEl, recsEl, historyEl, riExchangePanel);

(api.listConvertibleRIs as jest.Mock).mockResolvedValue([]);
(api.listExchangeableAzureRIs as jest.Mock).mockResolvedValue([]);
(api.getRIUtilization as jest.Mock).mockResolvedValue([]);
(api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [], recs_staleness: '', recs_collected_at: null });
(api.getRIExchangeHistory as jest.Mock).mockResolvedValue([]);

_providerListeners.length = 0;
_accountListeners.length = 0;
const s = stateMod();
s.subscribeProvider.mockImplementation((cb: () => void) => { _providerListeners.push(cb); return () => undefined; });
s.subscribeAccount.mockImplementation((cb: () => void) => { _accountListeners.push(cb); return () => undefined; });
s.getCurrentProvider.mockReturnValue('aws');
s.getCurrentAccountIDs.mockReturnValue([]);
});

afterEach(() => {
document.body.innerHTML = '';
jest.clearAllMocks();
});

it('forwards the single selected account to the AWS list endpoint', async () => {
stateMod().getCurrentAccountIDs.mockReturnValue(['123456789012']);
await loadRIExchange();
expect(api.listConvertibleRIs).toHaveBeenCalledWith('123456789012');
});

it('does not forward an account id when more than one account is selected', async () => {
stateMod().getCurrentAccountIDs.mockReturnValue(['111111111111', '222222222222']);
await loadRIExchange();
expect(api.listConvertibleRIs).toHaveBeenCalledWith(undefined);
});

it('renders a scoped empty-state naming the AWS account', async () => {
stateMod().getCurrentAccountIDs.mockReturnValue(['123456789012']);
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([]);
await loadRIExchange();
expect(instancesEl.textContent).toContain('123456789012');
});

it('loads Azure reservations (not AWS RIs) when provider is azure', async () => {
stateMod().getCurrentProvider.mockReturnValue('azure');
(api.listExchangeableAzureRIs as jest.Mock).mockResolvedValue([
{ reservation_order_id: 'o1', reservation_id: 'r1', sku: 'Standard_D2s_v3', quantity: 2, region: 'eastus', term: 'P1Y', instance_flexibility: 'On', display_name: 'web-rsv' },
]);
await loadRIExchange();
expect(api.listExchangeableAzureRIs).toHaveBeenCalled();
expect(api.listConvertibleRIs).not.toHaveBeenCalled();
expect(instancesEl.textContent).toContain('Standard_D2s_v3');
expect(instancesEl.textContent).toContain('web-rsv');
// Reshape recommendations are AWS-only -> provider-aware not-applicable copy.
expect(recsEl.textContent).toContain('not available for Azure');
});

it('shows a provider-aware empty state for Azure with no reservations', async () => {
stateMod().getCurrentProvider.mockReturnValue('azure');
stateMod().getCurrentAccountIDs.mockReturnValue(['sub-abc']);
(api.listExchangeableAzureRIs as jest.Mock).mockResolvedValue([]);
await loadRIExchange();
expect(api.listExchangeableAzureRIs).toHaveBeenCalledWith('sub-abc');
expect(instancesEl.textContent).toContain('No exchangeable reservations for Azure subscription sub-abc');
});

it('shows the not-available empty state for GCP and calls no list endpoint', async () => {
stateMod().getCurrentProvider.mockReturnValue('gcp');
await loadRIExchange();
expect(api.listConvertibleRIs).not.toHaveBeenCalled();
expect(api.listExchangeableAzureRIs).not.toHaveBeenCalled();
expect(instancesEl.textContent).toContain("isn't available for GCP");
expect(recsEl.textContent).toContain("isn't available for GCP");
expect(historyEl.textContent).toContain("isn't available for GCP");
});

it('does not leave stale AWS rows when switching to GCP', async () => {
// First load AWS with a row present.
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([{
reserved_instance_id: 'ri-1', instance_type: 'm5.large', availability_zone: 'us-east-1a',
instance_count: 1, start: '2026-01-01T00:00:00Z', end: '2027-01-01T00:00:00Z',
offering_type: 'Convertible', fixed_price: 0, usage_price: 0, state: 'active', normalization_factor: 4,
}]);
await loadRIExchange();
expect(instancesEl.textContent).toContain('m5.large');
// Switch to GCP -> table must be replaced by the empty state.
stateMod().getCurrentProvider.mockReturnValue('gcp');
await loadRIExchange();
expect(instancesEl.textContent).not.toContain('m5.large');
expect(instancesEl.textContent).toContain("isn't available for GCP");
});

it('closes an in-progress exchange modal when the filter changes', async () => {
const modal = createModal();
modal.classList.remove('hidden'); // simulate an open exchange modal
setupRIExchangeHandlers();
_providerListeners.forEach(cb => cb());
// Modal is closed synchronously on the chip event.
expect(modal.classList.contains('hidden')).toBe(true);
});
});
2 changes: 2 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type {
SavingsBreakdownValue,
SavingsAnalyticsFilters,
ConvertibleRI,
ExchangeableAzureRI,
RIUtilization,
ReshapeRecommendation,
OfferingOption,
Expand Down Expand Up @@ -181,6 +182,7 @@ export {
export type { ReshapeRecommendationsResponse } from './riexchange';
export {
listConvertibleRIs,
listExchangeableAzureRIs,
getRIUtilization,
getReshapeRecommendations,
getExchangeQuote,
Expand Down
25 changes: 22 additions & 3 deletions frontend/src/api/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { apiRequest } from './client';
import type {
ConvertibleRI,
ExchangeableAzureRI,
RIUtilization,
ReshapeRecommendation,
ExchangeQuoteRequest,
Expand All @@ -17,13 +18,31 @@ import type {
} from './types';

/**
* List active convertible Reserved Instances
* List active convertible Reserved Instances for the running AWS account.
*
* The optional accountID scopes the listing to a single AWS account so the
* page honours the Main Header global account filter (issue #871). The
* backend returns an empty list when the selected account is not the running
* AWS account.
*/
export async function listConvertibleRIs(): Promise<ConvertibleRI[]> {
const resp = await apiRequest<{ instances: ConvertibleRI[] }>('/ri-exchange/instances');
export async function listConvertibleRIs(accountID?: string): Promise<ConvertibleRI[]> {
const qs = accountID ? `?account_id=${encodeURIComponent(accountID)}` : '';
const resp = await apiRequest<{ instances: ConvertibleRI[] }>(`/ri-exchange/instances${qs}`);
return resp.instances ?? [];
}

/**
* List active Azure VM reservations eligible for the cross-SKU/cross-region
* exchange flow (issue #871). The optional subscriptionID scopes the
* capacity-provider registration check to one subscription; the listing
* itself is tenant-wide on the backend.
*/
export async function listExchangeableAzureRIs(subscriptionID?: string): Promise<ExchangeableAzureRI[]> {
const qs = subscriptionID ? `?subscription_id=${encodeURIComponent(subscriptionID)}` : '';
const resp = await apiRequest<{ reservations: ExchangeableAzureRI[] }>(`/ri-exchange/azure-instances${qs}`);
return resp.reservations ?? [];
}

/**
* Get per-RI utilization from Cost Explorer
*/
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,22 @@ export interface ConvertibleRI {
normalization_factor: number;
}

// ExchangeableAzureRI is one Azure VM reservation eligible for the
// cross-SKU/cross-region exchange flow, returned by
// GET /api/ri-exchange/azure-instances. Mirrors the Go
// azurecompute.ExchangeableReservation struct (issue #871).
export interface ExchangeableAzureRI {
reservation_order_id: string;
reservation_id: string;
sku: string;
quantity: number;
region?: string;
term?: string;
expiry_date?: string;
instance_flexibility: string;
display_name?: string;
}

export interface RIUtilization {
reserved_instance_id: string;
utilization_percent: number;
Expand Down
Loading
Loading