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
2 changes: 1 addition & 1 deletion frontend/src/__tests__/riexchange-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
80 changes: 69 additions & 11 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
loadReshapeRecommendations,
loadRIExchange,
openExchangeModal,
renderReshapeStalenessBanner,
setupRIExchangeHandlers,
} from '../riexchange';
import * as api from '../api';
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export {
} from './inventory';

// Re-export RI exchange functions
export type { ReshapeRecommendationsResponse } from './riexchange';
export {
listConvertibleRIs,
getRIUtilization,
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/api/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,27 @@ export async function getRIUtilization(lookbackDays?: number): Promise<RIUtiliza
return resp.utilization ?? [];
}

/**
* Reshape recommendations response, including the Cost Explorer cache
* freshness fields populated by the backend.
*
* recs_staleness is "" when the underlying cache is fresh, "soft" when
* it is older than 12 h, and "hard" when it is older than 24 h.
* recs_collected_at carries the raw ISO-8601 timestamp so the banner
* can display a relative-time label ("last collected 23h ago").
*/
export interface ReshapeRecommendationsResponse {
recommendations: ReshapeRecommendation[];
recs_staleness?: string;
recs_collected_at?: string | null;
}

/**
* Get automated reshape recommendations
*/
export async function getReshapeRecommendations(threshold?: number): Promise<ReshapeRecommendation[]> {
export async function getReshapeRecommendations(threshold?: number): Promise<ReshapeRecommendationsResponse> {
const params = threshold !== undefined ? `?threshold=${threshold}` : '';
const resp = await apiRequest<{ recommendations: ReshapeRecommendation[] }>(`/ri-exchange/reshape-recommendations${params}`);
return resp.recommendations ?? [];
return apiRequest<ReshapeRecommendationsResponse>(`/ri-exchange/reshape-recommendations${params}`);
}

/**
Expand Down
60 changes: 59 additions & 1 deletion frontend/src/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
RIExchangeHistoryRecord,
OfferingOption,
TargetOffering,
ReshapeRecommendationsResponse,
} from './api';
import { openModal, closeModal } from './modal';
import { showSkeletonRows, teardownSkeleton } from './lib/skeleton';
Expand Down Expand Up @@ -189,15 +190,72 @@ export async function loadReshapeRecommendations(): Promise<void> {
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;
container.innerHTML = `<p class="error">Failed to load recommendations: ${escapeHtml(err.message)}</p>`;
}
}

/**
* 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
Expand Down
55 changes: 54 additions & 1 deletion internal/api/handler_ri_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions internal/api/handler_ri_exchange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Loading