Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 4 additions & 2 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,11 @@ export {
resumePlannedPurchase,
runPlannedPurchase,
deletePlannedPurchase,
createPlannedPurchases
createPlannedPurchases,
createMarketplaceListing,
cancelMarketplaceListing
} from './purchases';
export type { RetryPurchaseResult } from './purchases';
export type { RetryPurchaseResult, MarketplacePriceTier, MarketplaceListResult } from './purchases';

// Re-export users functions
export {
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/api/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,48 @@ export async function createPlannedPurchases(planId: string, count: number, star
body: JSON.stringify({ count, start_date: startDate })
});
}

// ── RI Marketplace (issue #292) ───────────────────────────────────────────────

export interface MarketplacePriceTier {
/** Number of months remaining this price tier covers. */
term_months: number;
/** USD list price per unit for this tier. */
price: number;
}

export interface MarketplaceListResult {
listing_id: string;
listing_state: string;
price_schedule: MarketplacePriceTier[];
aws_fee_percent: number;
note?: string;
}

/**
* Create an AWS RI Marketplace listing for a Standard Reserved Instance.
* purchaseId is the purchase_history.purchase_id (AWS ReservedInstancesId).
* priceSchedule is optional: when omitted the backend computes a default.
*/
export async function createMarketplaceListing(
purchaseId: string,
priceSchedule?: MarketplacePriceTier[],
): Promise<MarketplaceListResult> {
const body = priceSchedule && priceSchedule.length > 0
? JSON.stringify({ price_schedule: priceSchedule })
: undefined;
return apiRequest<MarketplaceListResult>(`/purchases/${purchaseId}/marketplace-list`, {
method: 'POST',
...(body ? { body } : {}),
});
}

/**
* Cancel an active AWS RI Marketplace listing.
*/
export async function cancelMarketplaceListing(purchaseId: string): Promise<{ listing_id: string; listing_state: string }> {
return apiRequest<{ listing_id: string; listing_state: string }>(
`/purchases/${purchaseId}/marketplace-cancel`,
{ method: 'POST' },
);
}
113 changes: 112 additions & 1 deletion frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,39 @@ function retryThresholdReached(p: HistoryPurchase): boolean {
return (p.retry_attempt_n ?? 0) >= RETRY_THRESHOLD;
}

// canSellOnMarketplace returns true when the current session is permitted
// to list the given completed history row on the AWS RI Marketplace
// (issue #292). UX gate only -- the backend authorizeSessionSell in
// internal/api/handler_marketplace.go remains the security boundary; a
// false-positive here surfaces as a 403 toast on click.
//
// Conditions:
// * row must be a completed purchase (status "completed" or absent);
// * offering_class must be "standard";
// * no active listing already (listing_state != "active"); and
// * admin, or non-admin user (sell-own covers their own accounts --
// we can't efficiently check per-account ownership client-side, so
// we show the button for all non-admin users and let the backend 403
// when the account is out of scope).
function canSellOnMarketplace(p: HistoryPurchase): boolean {
const status = normalizeStatus(p).toLowerCase();
if (status !== 'completed') return false;
if ((p.offering_class || '').toLowerCase() !== 'standard') return false;
if ((p.listing_state || '').toLowerCase() === 'active') return false;
const user = getCurrentUser();
if (!user) return false;
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// canCancelMarketplaceListing returns true when there is an active listing
// that the current session can cancel.
function canCancelMarketplaceListing(p: HistoryPurchase): boolean {
if ((p.listing_state || '').toLowerCase() !== 'active') return false;
const user = getCurrentUser();
if (!user) return false;
return true;
}

// shortExecID renders the first 8 chars of a UUID so inline lineage
// links ("Retried as #abc12345") stay readable in the table cell. The
// full ID is preserved in the data-history-status attribute so the
Expand All @@ -493,7 +526,9 @@ function sameRowActions(btn: HTMLButtonElement): HTMLButtonElement[] {
const cell = btn.closest('td') || btn.parentElement;
if (!cell) return [btn];
return Array.from(
cell.querySelectorAll<HTMLButtonElement>('.history-approve-btn, .history-cancel-btn'),
cell.querySelectorAll<HTMLButtonElement>(
'.history-approve-btn, .history-cancel-btn, .history-marketplace-sell-btn, .history-marketplace-cancel-btn',
),
);
}

Expand Down Expand Up @@ -576,6 +611,18 @@ function renderActionCell(p: HistoryPurchase): string {
return lineage.join(' ');
}

// Completed Standard RI rows: offer Sell on Marketplace (issue #292).
// The button is placed here (after lineage links) so it does not
// interfere with the Retry / Approve / Cancel affordances above.
if (p.purchase_id) {
if (canCancelMarketplaceListing(p)) {
return `<button type="button" class="btn-link history-marketplace-cancel-btn" data-marketplace-cancel-id="${escapeHtml(p.purchase_id)}">Cancel listing ${escapeHtml(p.listing_id || '')}</button>`;
}
if (canSellOnMarketplace(p)) {
return `<button type="button" class="btn-link history-marketplace-sell-btn" data-marketplace-sell-id="${escapeHtml(p.purchase_id)}">Sell on Marketplace</button>`;
}
}

return escapeHtml(p.plan_name || '-');
}

Expand Down Expand Up @@ -863,6 +910,70 @@ function wireRowActionHandlers(container: HTMLElement): void {
}
});
});

// Wire Sell on Marketplace button (issue #292)
container.querySelectorAll<HTMLButtonElement>('.history-marketplace-sell-btn[data-marketplace-sell-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset['marketplaceSellId'];
if (!id) return;
const ok = await confirmDialog({
title: 'List this RI on the AWS Marketplace?',
body: 'This will create a binding listing on the AWS Marketplace. AWS charges a 12% transaction fee on proceeds. Make sure your AWS account has a US bank account on file as a Marketplace seller. This action cannot be undone without cancelling the listing.',
confirmLabel: 'Sell on Marketplace',
destructive: false,
});
if (!ok) return;
const rowActions = sameRowActions(btn);
rowActions.forEach(b => { b.disabled = true; });
try {
await api.createMarketplaceListing(id);
} catch (sellError) {
console.error('Failed to list RI on Marketplace:', sellError);
const err = sellError as Error;
showToast({ message: `Failed to list on Marketplace: ${err.message || 'unknown error'}`, kind: 'error' });
rowActions.forEach(b => { b.disabled = false; });
return;
}
showToast({ message: 'RI listed on Marketplace successfully', kind: 'success', timeout: 5_000 });
try {
await loadHistory();
} catch (reloadError) {
console.error('Failed to reload history after Marketplace listing:', reloadError);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

// Wire Cancel listing button (issue #292)
container.querySelectorAll<HTMLButtonElement>('.history-marketplace-cancel-btn[data-marketplace-cancel-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset['marketplaceCancelId'];
if (!id) return;
const ok = await confirmDialog({
title: 'Cancel this Marketplace listing?',
body: 'This will remove the listing from the AWS Marketplace. Any existing buyer negotiations will be cancelled. You can relist the RI at any time.',
confirmLabel: 'Cancel listing',
destructive: true,
});
if (!ok) return;
const rowActions = sameRowActions(btn);
rowActions.forEach(b => { b.disabled = true; });
try {
await api.cancelMarketplaceListing(id);
} catch (cancelError) {
console.error('Failed to cancel Marketplace listing:', cancelError);
const err = cancelError as Error;
showToast({ message: `Failed to cancel listing: ${err.message || 'unknown error'}`, kind: 'error' });
rowActions.forEach(b => { b.disabled = false; });
return;
}
showToast({ message: 'Marketplace listing cancelled', kind: 'success', timeout: 5_000 });
try {
await loadHistory();
} catch (reloadError) {
console.error('Failed to reload history after Marketplace cancel:', reloadError);
}
});
});
}

// isPendingRow returns true when a history row represents a purchase
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,17 @@ export interface HistoryPurchase {
// absent otherwise. Replaces the Retry button entirely — there is
// no actionable retry from a persistent misconfig.
ops_hint?: string;
// OfferingClass is "standard" or "convertible" for EC2 RIs. Absent
// on non-EC2 rows and on rows written before migration 000060.
// The "Sell on Marketplace" button only renders when this equals
// "standard" (issue #292).
offering_class?: string;
// ListingID is the AWS ReservedInstancesListingId. Non-empty when an
// active or recently-closed marketplace listing exists (issue #292).
listing_id?: string;
// ListingState is the AWS marketplace listing state: "active",
// "cancelled", or "closed". Empty when not listed.
listing_state?: string;
}

// Savings Analytics types
Expand Down
7 changes: 7 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,13 @@ func (m *mockConfigStore) UpsertRIUtilizationCache(_ context.Context, _ string,
return nil
}

func (m *mockConfigStore) GetPurchaseHistoryByPurchaseID(_ context.Context, _ string) (*config.PurchaseHistoryRecord, error) {
return nil, nil
}
func (m *mockConfigStore) UpdatePurchaseHistoryListing(_ context.Context, _, _, _ string) error {
return nil
}

// TestNewCollector tests the NewCollector function
func TestNewCollector(t *testing.T) {
t.Run("returns error when analytics store is nil", func(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ type Handler struct {
// armreservations-backed client.
azureExchangeFactory func(subscriptionID string) azureExchangeClient

// Optional marketplace EC2 client factory injected by tests. When nil
// (the production default), buildMarketplaceEC2Client uses
// awsprovider.NewEC2ClientDirect.
marketplaceEC2Factory func(aws.Config) marketplaceEC2Client

// Optional account-resolver injection point used by the reshape
// handler integration test. When nil (the production default), the
// handler calls h.resolveAWSCloudAccountID which in turn invokes
Expand Down
Loading
Loading