diff --git a/frontend/src/__tests__/riexchange-permissions.test.ts b/frontend/src/__tests__/riexchange-permissions.test.ts index 24e10aa5c..1d0ca8998 100644 --- a/frontend/src/__tests__/riexchange-permissions.test.ts +++ b/frontend/src/__tests__/riexchange-permissions.test.ts @@ -75,7 +75,7 @@ describe('RI Exchange tables permission gating (issue #365)', () => { jest.clearAllMocks(); setupDom(); (api.listConvertibleRIs as jest.Mock).mockResolvedValue([sampleRI]); - (api.getReshapeRecommendations as jest.Mock).mockResolvedValue([sampleReshape]); + (api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [sampleReshape], recs_staleness: '', recs_collected_at: null }); }); describe('admin role', () => { diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index af3a25844..ad999db3c 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -29,6 +29,7 @@ import { loadReshapeRecommendations, loadRIExchange, openExchangeModal, + renderReshapeStalenessBanner, setupRIExchangeHandlers, } from '../riexchange'; import * as api from '../api'; @@ -440,15 +441,19 @@ describe('reshape recommendations table', () => { it('renders the Alternatives column with cost chips when the rec carries alternative_targets', async () => { const mockGet = api.getReshapeRecommendations as jest.Mock; - mockGet.mockResolvedValueOnce([ - { - ...baseRec, - alternative_targets: [ - { instance_type: 'm7g.large', offering_id: 'off-m7g', effective_monthly_cost: 30.0 }, - { instance_type: 'm6i.large', offering_id: 'off-m6i', effective_monthly_cost: 35.0 }, - ], - }, - ]); + mockGet.mockResolvedValueOnce({ + recommendations: [ + { + ...baseRec, + alternative_targets: [ + { instance_type: 'm7g.large', offering_id: 'off-m7g', effective_monthly_cost: 30.0 }, + { instance_type: 'm6i.large', offering_id: 'off-m6i', effective_monthly_cost: 35.0 }, + ], + }, + ], + recs_staleness: '', + recs_collected_at: null, + }); await loadReshapeRecommendations(); @@ -466,7 +471,7 @@ describe('reshape recommendations table', () => { it('renders an em-dash in the Alternatives column when the rec has no alternative_targets', async () => { const mockGet = api.getReshapeRecommendations as jest.Mock; - mockGet.mockResolvedValueOnce([{ ...baseRec }]); // no alternative_targets + mockGet.mockResolvedValueOnce({ recommendations: [{ ...baseRec }], recs_staleness: '', recs_collected_at: null }); // no alternative_targets await loadReshapeRecommendations(); @@ -514,7 +519,7 @@ describe('reshape recommendations empty state', () => { (api.getRIUtilization as jest.Mock).mockResolvedValue([]); (api.getRIExchangeHistory as jest.Mock).mockResolvedValue([]); - (api.getReshapeRecommendations as jest.Mock).mockResolvedValue([]); + (api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [], recs_staleness: '', recs_collected_at: null }); }); afterEach(() => { @@ -544,6 +549,59 @@ describe('reshape recommendations empty state', () => { }); }); +// Staleness banner tests for issue #150. +describe('renderReshapeStalenessBanner', () => { + let listEl: HTMLDivElement; + + beforeEach(() => { + const wrapper = document.createElement('div'); + listEl = document.createElement('div'); + listEl.id = 'ri-exchange-recommendations-list'; + wrapper.appendChild(listEl); + document.body.appendChild(wrapper); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('renders no banner when staleness is empty', () => { + renderReshapeStalenessBanner('', null); + const banner = document.getElementById('ri-exchange-recommendations-freshness'); + expect(banner?.textContent).toBe(''); + }); + + it('renders a soft-warning banner for staleness=soft', () => { + renderReshapeStalenessBanner('soft', null); + const banner = document.getElementById('ri-exchange-recommendations-freshness'); + expect(banner?.className).toContain('warning'); + expect(banner?.textContent).toContain('may be up to 24h old'); + }); + + it('renders a hard-warning banner for staleness=hard', () => { + renderReshapeStalenessBanner('hard', null); + const banner = document.getElementById('ri-exchange-recommendations-freshness'); + expect(banner?.className).toContain('error'); + expect(banner?.textContent).toContain('older than 24h'); + }); + + it('includes an age label when recs_collected_at is provided', () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + renderReshapeStalenessBanner('soft', twoHoursAgo); + const banner = document.getElementById('ri-exchange-recommendations-freshness'); + expect(banner?.textContent).toContain('last collected 2h'); + }); + + it('clears an existing banner when staleness becomes empty', () => { + renderReshapeStalenessBanner('hard', null); + expect(document.getElementById('ri-exchange-recommendations-freshness')?.className).toContain('error'); + renderReshapeStalenessBanner('', null); + const banner = document.getElementById('ri-exchange-recommendations-freshness'); + expect(banner?.textContent).toBe(''); + expect(banner?.className).toBe(''); + }); +}); + describe('⚙︎ Exchange settings deep-link', () => { beforeEach(() => { const btn = document.createElement('button'); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 62f4524af..c78176fe2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -174,6 +174,7 @@ export { } from './inventory'; // Re-export RI exchange functions +export type { ReshapeRecommendationsResponse } from './riexchange'; export { listConvertibleRIs, getRIUtilization, diff --git a/frontend/src/api/riexchange.ts b/frontend/src/api/riexchange.ts index c3366282b..850aa64c1 100644 --- a/frontend/src/api/riexchange.ts +++ b/frontend/src/api/riexchange.ts @@ -33,13 +33,27 @@ export async function getRIUtilization(lookbackDays?: number): Promise { +export async function getReshapeRecommendations(threshold?: number): Promise { const params = threshold !== undefined ? `?threshold=${threshold}` : ''; - const resp = await apiRequest<{ recommendations: ReshapeRecommendation[] }>(`/ri-exchange/reshape-recommendations${params}`); - return resp.recommendations ?? []; + return apiRequest(`/ri-exchange/reshape-recommendations${params}`); } /** diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index aee5f2cb0..abaf96024 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -16,6 +16,7 @@ import type { RIExchangeHistoryRecord, OfferingOption, TargetOffering, + ReshapeRecommendationsResponse, } from './api'; import { openModal, closeModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; @@ -189,8 +190,10 @@ export async function loadReshapeRecommendations(): Promise { showSkeletonRows(container, 3, 8); try { - currentRecommendations = await api.getReshapeRecommendations(); + const resp: ReshapeRecommendationsResponse = await api.getReshapeRecommendations(); + currentRecommendations = resp.recommendations ?? []; renderRecommendations(container); + renderReshapeStalenessBanner(resp.recs_staleness, resp.recs_collected_at); } catch (error) { teardownSkeleton(container); const err = error as Error; @@ -198,6 +201,61 @@ export async function loadReshapeRecommendations(): Promise { } } +/** + * Render (or clear) the staleness banner above the reshape-recommendations + * table. The banner slot is a sibling element with id + * "ri-exchange-recommendations-freshness"; it is created here if absent. + * + * staleness: "" or undefined clears any existing banner (fresh data). + * "soft" : soft-warning copy ("data may be up to 12 h old"). + * "hard" : hard-warning copy ("data is more than 24 h old"). + */ +export function renderReshapeStalenessBanner( + staleness: string | undefined, + collectedAt: string | null | undefined, +): void { + const BANNER_ID = 'ri-exchange-recommendations-freshness'; + // Locate or create the banner slot. It lives immediately before + // ri-exchange-recommendations-list in the DOM. + let banner = document.getElementById(BANNER_ID); + if (!banner) { + const listEl = document.getElementById('ri-exchange-recommendations-list'); + if (!listEl || !listEl.parentElement) return; + banner = document.createElement('div'); + banner.id = BANNER_ID; + listEl.parentElement.insertBefore(banner, listEl); + } + + if (!staleness) { + banner.textContent = ''; + banner.className = ''; + return; + } + + // Build the age label from collectedAt when available. + let ageLabel = ''; + if (collectedAt) { + const ms = Date.now() - new Date(collectedAt).getTime(); + const hours = Math.floor(ms / (1000 * 60 * 60)); + const mins = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); + ageLabel = hours > 0 ? ` (last collected ${hours}h${mins > 0 ? ` ${mins}m` : ''} ago)` : ` (last collected ${mins}m ago)`; + } + + const isSoft = staleness === 'soft'; + banner.className = isSoft ? 'freshness-banner warning' : 'freshness-banner error'; + + const icon = isSoft ? '!' : '!!'; + const copy = isSoft + ? `Cross-family alternatives are based on Cost Explorer recommendations that may be up to 24h old${ageLabel}. Some prices may be stale.` + : `Cross-family alternatives are based on Cost Explorer recommendations older than 24h${ageLabel}. Prices may be significantly out of date.`; + + banner.textContent = ''; + const strong = document.createElement('strong'); + strong.textContent = icon + ' '; + banner.appendChild(strong); + banner.appendChild(document.createTextNode(copy)); +} + function renderRecommendations(container: HTMLElement): void { if (!currentRecommendations || currentRecommendations.length === 0) { // The "well-utilized" copy is only truthful when the RI fleet actually diff --git a/internal/api/handler_ri_exchange.go b/internal/api/handler_ri_exchange.go index f21b2457f..18c4915cb 100644 --- a/internal/api/handler_ri_exchange.go +++ b/internal/api/handler_ri_exchange.go @@ -431,7 +431,29 @@ func (h *Handler) getReshapeRecommendations(ctx context.Context, req *events.Lam lookup := purchaseRecLookupFromStore(h.config, cloudAccountID) recs := exchange.AnalyzeReshapingWithRecs(ctx, riInfos, utilInfos, threshold, region, currencyCode, lookup) - return &ReshapeRecommendationsResponse{Recommendations: recs}, nil + resp := &ReshapeRecommendationsResponse{Recommendations: recs} + h.attachReshapeStaleness(ctx, resp) + return resp, nil +} + +// attachReshapeStaleness populates the RecsStaleness and RecsCollectedAt +// fields on resp from the recommendations_state table. Non-fatal: errors +// are logged and the response ships without staleness metadata so the +// reshape table itself is unaffected by a DB read-side failure. +func (h *Handler) attachReshapeStaleness(ctx context.Context, resp *ReshapeRecommendationsResponse) { + freshness, err := h.config.GetRecommendationsFreshness(ctx) + if err != nil { + logging.Warnf("getReshapeRecommendations: could not check recs freshness (banner suppressed): %v", err) + return + } + resp.RecsCollectedAt = freshness.LastCollectedAt + if freshness.LastCollectedAt == nil { + // Cold start: cache was never populated — treat as hard-stale so the + // banner fires on a fresh deployment rather than silently hiding it. + resp.RecsStaleness = "hard" + } else { + resp.RecsStaleness = classifyRecsAge(time.Since(*freshness.LastCollectedAt)) + } } // firstNonEmptyCurrency returns the CurrencyCode of the first RI that @@ -617,8 +639,39 @@ type RIUtilizationResponse struct { } // ReshapeRecommendationsResponse holds reshape recommendations. +// +// RecsStaleness is empty when the underlying Cost Explorer cache is +// fresh, "soft" when it is older than reshapeSoftStaleThreshold (12 h), +// and "hard" when it is older than reshapeHardStaleThreshold (24 h). +// RecsCollectedAt carries the raw timestamp so the frontend can build +// its own relative-time label ("last collected 23h ago"). type ReshapeRecommendationsResponse struct { Recommendations []exchange.ReshapeRecommendation `json:"recommendations"` + RecsStaleness string `json:"recs_staleness,omitempty"` + RecsCollectedAt *time.Time `json:"recs_collected_at,omitempty"` +} + +// reshapeSoftStaleThreshold is the age at which the reshape recs banner +// transitions to "soft" warning: data may be up to 12 h old. +const reshapeSoftStaleThreshold = 12 * time.Hour + +// reshapeHardStaleThreshold is the age at which the reshape recs banner +// transitions to "hard" warning: data is more than 24 h old. +const reshapeHardStaleThreshold = 24 * time.Hour + +// classifyRecsAge maps a data age to the staleness label surfaced in +// ReshapeRecommendationsResponse.RecsStaleness. The zero duration +// (cold-cache path: no LastCollectedAt) is treated as "hard" so the +// banner fires on a fresh deployment rather than silently hiding it. +func classifyRecsAge(age time.Duration) string { + switch { + case age >= reshapeHardStaleThreshold: + return "hard" + case age >= reshapeSoftStaleThreshold: + return "soft" + default: + return "" + } } // ExchangeTargetBody is one entry in an ExchangeQuote/Execute request's diff --git a/internal/api/handler_ri_exchange_test.go b/internal/api/handler_ri_exchange_test.go index 4eb0709f8..a107327cc 100644 --- a/internal/api/handler_ri_exchange_test.go +++ b/internal/api/handler_ri_exchange_test.go @@ -895,3 +895,38 @@ func TestMapAWSExchangeError_NonAWSError(t *testing.T) { require.True(t, ok) assert.Equal(t, 500, ce.code) } + +// TestClassifyRecsAge pins the staleness classification thresholds for +// the reshape freshness banner. The three transitions are: +// +// - < 12 h → "" (fresh, no banner) +// - 12–24 h → "soft" (warning banner) +// - >= 24 h → "hard" (critical banner) +func TestClassifyRecsAge(t *testing.T) { + t.Parallel() + cases := []struct { + name string + age time.Duration + want string + }{ + {"zero age is fresh", 0, ""}, + {"30 minutes is fresh", 30 * time.Minute, ""}, + {"just under soft threshold", reshapeSoftStaleThreshold - time.Minute, ""}, + {"exactly soft threshold", reshapeSoftStaleThreshold, "soft"}, + {"13 hours is soft", 13 * time.Hour, "soft"}, + {"just under hard threshold", reshapeHardStaleThreshold - time.Minute, "soft"}, + {"exactly hard threshold", reshapeHardStaleThreshold, "hard"}, + {"25 hours is hard", 25 * time.Hour, "hard"}, + {"48 hours is hard", 48 * time.Hour, "hard"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := classifyRecsAge(tc.age) + if got != tc.want { + t.Errorf("classifyRecsAge(%v) = %q, want %q", tc.age, got, tc.want) + } + }) + } +}