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
99 changes: 94 additions & 5 deletions frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Recommendations module tests
*/
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection } from '../recommendations';
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection, renderUsageSparkline } from '../recommendations';
import type { CostPeriod } from '../state';

// Mock the api module
Expand Down Expand Up @@ -5252,7 +5252,7 @@ describe('Column visibility (issue #318)', () => {
// --- TOGGLEABLE_COLUMNS and COLUMN_DEFS ---

describe('COLUMN_DEFS and TOGGLEABLE_COLUMNS', () => {
test('COLUMN_DEFS contains all 13 column ids', () => {
test('COLUMN_DEFS contains all 14 column ids (13 data + usage_history sparkline)', () => {
const keys = COLUMN_DEFS.map((c) => c.key);
expect(keys).toContain('provider');
expect(keys).toContain('account');
Expand All @@ -5267,7 +5267,9 @@ describe('Column visibility (issue #318)', () => {
expect(keys).toContain('monthly_cost');
expect(keys).toContain('on_demand_monthly');
expect(keys).toContain('effective_savings_pct');
expect(COLUMN_DEFS.length).toBe(13);
// issue #239: usage_history sparkline column added
expect(keys).toContain('usage_history');
expect(COLUMN_DEFS.length).toBe(14);
});

test('TOGGLEABLE_COLUMNS excludes fixed identity columns', () => {
Expand All @@ -5276,7 +5278,7 @@ describe('Column visibility (issue #318)', () => {
expect(keys).not.toContain('account');
expect(keys).not.toContain('service');
expect(keys).not.toContain('resource_type');
// All other 9 columns should be toggleable
// All other 10 columns (9 original + usage_history) should be toggleable.
expect(keys).toContain('region');
expect(keys).toContain('count');
expect(keys).toContain('term');
Expand All @@ -5286,7 +5288,8 @@ describe('Column visibility (issue #318)', () => {
expect(keys).toContain('monthly_cost');
expect(keys).toContain('on_demand_monthly');
expect(keys).toContain('effective_savings_pct');
expect(keys.length).toBe(9);
expect(keys).toContain('usage_history');
expect(keys.length).toBe(10);
});
});
});
Expand Down Expand Up @@ -6551,3 +6554,89 @@ describe('isHomogeneousSelection (#769)', () => {
expect(isHomogeneousSelection(recs as never[])).toBe(false);
});
});

// ---------------------------------------------------------------------------
// Issue #239: renderUsageSparkline unit tests
// ---------------------------------------------------------------------------
describe('renderUsageSparkline (issue #239)', () => {
test('returns em-dash for null', () => {
expect(renderUsageSparkline(null)).toBe('—');
});

test('returns em-dash for undefined', () => {
expect(renderUsageSparkline(undefined)).toBe('—');
});

test('returns em-dash for empty array', () => {
expect(renderUsageSparkline([])).toBe('—');
});

test('returns an SVG element for a single point', () => {
const html = renderUsageSparkline([75]);
expect(html).toContain('<svg');
expect(html).toContain('class="usage-sparkline"');
// Single-point path uses a circle.
expect(html).toContain('<circle');
expect(html).not.toContain('<polyline');
// Accessible label is present; aria-hidden is not.
expect(html).toContain('role="img"');
expect(html).toContain('aria-label=');
expect(html).not.toContain('aria-hidden');
});

test('returns a polyline SVG for 7 points', () => {
const pcts = [80, 85, 90, 70, 95, 100, 60];
const html = renderUsageSparkline(pcts);
expect(html).toContain('<svg');
expect(html).toContain('class="usage-sparkline"');
expect(html).toContain('<polyline');
// 7 points produce 7 coordinate pairs in the points attribute.
const match = html.match(/points="([^"]+)"/);
expect(match).not.toBeNull();
const pairs = match![1]!.trim().split(' ').filter(Boolean);
expect(pairs).toHaveLength(7);
// No raw user input is interpolated; values are numbers only, no XSS vector.
expect(html).not.toContain('<script');
// Accessible label is present; aria-hidden is not.
expect(html).toContain('role="img"');
expect(html).toContain('aria-label=');
expect(html).not.toContain('aria-hidden');
});

test('aria-label conveys coverage values for screen readers', () => {
const html = renderUsageSparkline([80, 90]);
expect(html).toContain('aria-label="RI coverage last 2 days: 80.0%, 90.0%"');
});

test('non-finite values are clamped to 0 in geometry and label', () => {
const html = renderUsageSparkline([NaN, Infinity, -Infinity]);
// All three are treated as 0; label reflects clamped values.
expect(html).toContain('aria-label="RI coverage last 3 days: 0.0%, 0.0%, 0.0%"');
// Geometry stays valid (cy must be 19.0 for 0%).
expect(html).toContain('<polyline');
});

test('100% coverage maps point to top of SVG (y near pad)', () => {
const html = renderUsageSparkline([100]);
// With pad=1 and innerH=18: y = 1 + 18*(1-100/100) = 1.0
expect(html).toContain('cy="1.0"');
});

test('0% coverage maps point to bottom of SVG (y near h-pad)', () => {
const html = renderUsageSparkline([0]);
// With pad=1 and innerH=18: y = 1 + 18*(1-0/100) = 19.0
expect(html).toContain('cy="19.0"');
});

test('first and last x-coordinates span the full SVG width', () => {
const pcts = [50, 60, 70, 80, 90, 85, 75];
const html = renderUsageSparkline(pcts);
const match = html.match(/points="([^"]+)"/);
expect(match).not.toBeNull();
const pairs = match![1]!.trim().split(' ').filter(Boolean);
// First x must be 0.0 (i=0 => x = 0/(7-1)*56 = 0).
expect(pairs[0]).toMatch(/^0\.0,/);
// Last x must be 56.0 (i=6 => x = 6/6*56 = 56).
expect(pairs[6]).toMatch(/^56\.0,/);
});
});
4 changes: 4 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ export interface Recommendation {
purchase_id?: string;
error?: string;
cloud_account_id?: string;
// usage_history carries the last 7 daily RI-coverage percentages (0-100,
// oldest-to-newest). Absent/null when the provider did not populate it
// (non-AWS providers or pre-#239 cached rows).
usage_history?: number[] | null;
}

export interface RecommendationFilters {
Expand Down
79 changes: 69 additions & 10 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,7 +1216,7 @@ export function pickBestVariantPerCell(recs: readonly LocalRecommendation[]): Lo
export interface ColumnDef {
key: state.RecommendationsColumnId;
label: string;
kind: 'numeric' | 'categorical';
kind: 'numeric' | 'categorical' | 'visual';
// Issue #480: direction applied on the first click of a previously-
// unsorted column. Text columns and most numerics get 'asc' (A→Z, low →
// high) per platform convention. Two exceptions stay 'desc': `savings`
Expand All @@ -1225,6 +1225,10 @@ export interface ColumnDef {
// clicks on the active column still toggle desc <-> asc regardless of
// this default.
defaultSortDirection?: 'asc' | 'desc';
// sortable defaults to true. Set to false for visual-only columns
// (e.g. the usage_history sparkline) that have no meaningful sort order.
// Visual columns also suppress the column-filter button.
sortable?: boolean;
}

export const COLUMN_DEFS: readonly ColumnDef[] = [
Expand All @@ -1241,6 +1245,9 @@ export const COLUMN_DEFS: readonly ColumnDef[] = [
{ key: 'monthly_cost', label: 'Monthly Cost', kind: 'numeric' },
{ key: 'on_demand_monthly', label: 'On-Demand Monthly', kind: 'numeric', defaultSortDirection: 'desc' },
{ key: 'effective_savings_pct', label: 'Effective %', kind: 'numeric' },
// usage_history is a visual-only sparkline column (closes #239 Part 2).
// sortable:false suppresses the sort header and column-filter button.
{ key: 'usage_history', label: 'Coverage (7d)', kind: 'visual', sortable: false },
];

// Issue #480: per-column default sort direction. Defaults to 'asc' unless
Expand Down Expand Up @@ -1356,13 +1363,14 @@ function categoricalCellValue(r: LocalRecommendation, col: state.Recommendations
case 'region': return r.region ?? '';
case 'term': return r.term == null ? '' : String(r.term);
case 'payment': return r.payment ?? '';
// Numeric columns shouldn't reach this branch; return empty for type-safety.
// Numeric / visual columns shouldn't reach this branch; return empty for type-safety.
case 'count':
case 'savings':
case 'upfront_cost':
case 'monthly_cost':
case 'on_demand_monthly':
case 'effective_savings_pct': return '';
case 'effective_savings_pct':
case 'usage_history': return '';
}
}

Expand All @@ -1385,15 +1393,16 @@ function numericCellValue(r: LocalRecommendation, col: state.RecommendationsColu
// Return NaN for null effective_savings_pct so any numeric predicate
// returns false rather than coincidentally matching 0.
case 'effective_savings_pct': return effectiveSavingsPct(r) ?? Number.NaN;
// Categorical columns shouldn't reach this branch; return NaN so any
// Categorical / visual columns shouldn't reach this branch; return NaN so any
// numeric predicate returns false rather than coincidentally matching 0.
case 'provider':
case 'account':
case 'service':
case 'resource_type':
case 'region':
case 'term':
case 'payment': return Number.NaN;
case 'payment':
case 'usage_history': return Number.NaN;
}
}

Expand Down Expand Up @@ -1426,7 +1435,7 @@ export function displayPrecision(col: state.RecommendationsColumnId, period: Cos
case 'upfront_cost':
// Always formatted via formatCurrency with default digits.
return CURRENCY_DEFAULT_DIGITS;
// Categorical columns never reach the numeric filter path; default
// Categorical / visual columns never reach the numeric filter path; default
// is irrelevant but match formatCurrency's default-digit count for safety.
case 'provider':
case 'account':
Expand All @@ -1435,6 +1444,7 @@ export function displayPrecision(col: state.RecommendationsColumnId, period: Cos
case 'region':
case 'term':
case 'payment':
case 'usage_history':
return CURRENCY_DEFAULT_DIGITS;
}
}
Expand Down Expand Up @@ -2388,6 +2398,44 @@ function formatPayment(payment: string | undefined): string {
return PAYMENT_DISPLAY_LABELS[payment] ?? payment;
}

// renderUsageSparkline returns an inline SVG polyline representing the
// usage_history coverage percentages (0-100, oldest-to-newest). Returns
// the em-dash character when pcts is null, undefined, or empty so the cell
// degrades gracefully for non-AWS providers and pre-#239 cached rows.
//
// The SVG is 56x20px. The polyline plots each point at x = i/(n-1) * 56
// and y = (1 - pct/100) * 18 + 1 so 0% maps to the bottom (y=19) and
// 100% maps to the top (y=1). A single-point series renders as a filled
// circle rather than a line so a "1 day" history doesn't look broken.
//
// No user content is interpolated into the SVG; all values are numbers
// so XSS is structurally impossible here.
export function renderUsageSparkline(pcts: number[] | null | undefined): string {
if (!pcts || pcts.length === 0) return '—';
const clamped = pcts.map((v) => {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(100, v));
});
const w = 56;
const h = 20;
const pad = 1;
const innerH = h - 2 * pad;
const n = clamped.length;
const aria = `RI coverage last ${n} days: ${clamped.map((v) => `${v.toFixed(1)}%`).join(', ')}`;
if (n === 1) {
const cy = pad + innerH * (1 - clamped[0]! / 100);
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${aria}" class="usage-sparkline"><circle cx="${(w / 2).toFixed(1)}" cy="${cy.toFixed(1)}" r="2.5" fill="currentColor"/></svg>`;
}
const points = clamped
.map((p, i) => {
const x = (i / (n - 1)) * w;
const y = pad + innerH * (1 - p / 100);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" role="img" aria-label="${aria}" class="usage-sparkline"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/></svg>`;
}

// renderColumnCell renders a single <td> for the given column key.
// All column cell rendering is centralised here so buildVariantRowMarkup
// can iterate over COLUMN_DEFS (or a visibility-filtered subset in
Expand Down Expand Up @@ -2432,6 +2480,12 @@ function renderColumnCell(key: state.RecommendationsColumnId, rec: LocalRecommen
return `<td>${formatCostForPeriod(onDemandMonthly(rec), ctx.period)}</td>`;
case 'effective_savings_pct':
return `<td${ctx.pctClass}>${ctx.pctText}</td>`;
case 'usage_history':
// Render a 7-point inline SVG sparkline of daily RI-coverage pcts.
// renderUsageSparkline returns "—" for null/absent so the cell
// degrades cleanly for non-AWS providers and pre-#239 cached rows.
// No escaping needed: renderUsageSparkline only interpolates numbers.
return `<td class="usage-sparkline-cell" title="RI coverage last 7 days">${renderUsageSparkline(rec.usage_history)}</td>`;
}
}

Expand Down Expand Up @@ -2491,9 +2545,14 @@ function buildListMarkup(
const label = filters[column] ? `Filter ${lbl} \u2014 currently active` : `Filter ${lbl}`;
return `<button type="button" class="column-filter-btn${active}" data-column="${column}" aria-haspopup="dialog" aria-expanded="false" aria-label="${label}" title="${label}">\u26db</button>`;
};
const sortHeader = (column: state.RecommendationsColumnId): string => {
const lbl = getColumnLabel(column, period);
return `<th class="sortable" data-sort="${column}" tabindex="0" role="button" aria-label="Sort by ${lbl}"><span>${lbl}</span>${sortIndicator(column, sort.column, sort.direction)}${filterBtn(column)}</th>`;
const colHeader = (col: ColumnDef): string => {
const lbl = getColumnLabel(col.key, period);
// Visual-only columns (e.g. usage_history sparkline) are not sortable and
// have no column-filter button; render a plain non-interactive <th>.
if (col.sortable === false) {
return `<th>${lbl}</th>`;
}
return `<th class="sortable" data-sort="${col.key}" tabindex="0" role="button" aria-label="Sort by ${lbl}"><span>${lbl}</span>${sortIndicator(col.key, sort.column, sort.direction)}${filterBtn(col.key)}</th>`;
};

// issues #225 + #226: group by cell, sort groups, then render.
Expand Down Expand Up @@ -2628,7 +2687,7 @@ function buildListMarkup(
<thead>
<tr>
${checkboxColHeader}
${visibleCols.map((c) => sortHeader(c.key)).join('')}
${visibleCols.map((c) => colHeader(c)).join('')}
</tr>
</thead>
<tbody>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type { Recommendation } from './api/types';
export type RecommendationsColumnId =
| 'provider' | 'account' | 'service' | 'resource_type' | 'region'
| 'count' | 'term' | 'payment' | 'savings' | 'upfront_cost'
| 'monthly_cost' | 'on_demand_monthly' | 'effective_savings_pct';
| 'monthly_cost' | 'on_demand_monthly' | 'effective_savings_pct'
| 'usage_history';

// Every visible column is sortable, so the sort column type is exactly the
// column id set. Aliasing here keeps the two in sync automatically — adding
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ export interface LocalRecommendation {
suppressed_count?: number;
suppression_expires_at?: string;
primary_suppression_execution_id?: string;
// usage_history carries the last 7 daily RI-coverage percentages (0-100,
// oldest-to-newest) returned by the AWS CE GetReservationCoverage API.
// null or absent means the collector did not populate it (non-AWS providers
// or pre-#239 cached rows); the cell renders "—" in that case.
usage_history?: number[] | null;
}

export interface RecommendationsSummary {
Expand Down
9 changes: 9 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,15 @@ type RecommendationRecord struct {
// newest created_at). The frontend badge deep-links to Purchase
// History filtered to this execution.
PrimarySuppressionExecutionID *string `json:"primary_suppression_execution_id,omitempty" dynamodbav:"primary_suppression_execution_id,omitempty"`
// UsageHistory is a short time-series of daily RI-coverage percentages
// (0-100) for the last N days of the lookback window, ordered from
// oldest to newest. nil means the collector did not populate it (e.g.
// provider not yet wired); an empty non-nil slice means the collector
// ran but returned no daily data. The frontend renders nil as "—" and
// a non-empty slice as a thumbnail sparkline. Stored inside the
// recommendations JSONB payload — no DDL change needed (closes #239
// Part 1 for AWS).
UsageHistory []float64 `json:"usage_history,omitempty" dynamodbav:"usage_history,omitempty"`
}

// PurchaseSuppression records the per-tuple grace window after a bulk
Expand Down
1 change: 1 addition & 0 deletions internal/scheduler/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,7 @@ func (s *Scheduler) convertRecommendations(recs []common.Recommendation, provide
MonthlyCost: rec.RecurringMonthlyCost, // nil when provider API didn't return a monthly breakdown
Savings: rec.EstimatedSavings,
OnDemandCost: nonZeroPtr(rec.OnDemandCost), // nil when provider API didn't return a baseline; frontend falls back to reconstruction (#274)
UsageHistory: rec.UsageHistory, // daily coverage pcts (nil when provider not yet wired; see #239)
Selected: true, // Default to selected
Purchased: false,
})
Expand Down
8 changes: 8 additions & 0 deletions pkg/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ type Recommendation struct {
// RawRecommendation holds the original cloud API response bytes for audit/debugging.
// omitempty ensures nil is absent from JSON (not written as null).
RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty" csv:"-"`

// UsageHistory is an ordered slice of daily coverage/utilisation
// percentages (0-100) for the last N days of the lookback window
// (oldest-to-newest). Populated by cloud collectors that can source the
// signal from the provider API; nil when not yet wired or when the
// provider returned no data (frontend renders "—" in that case).
// Not written to CSV (no column heading — enrichment-only field).
UsageHistory []float64 `json:"usage_history,omitempty" csv:"-"`
}

// ServiceDetails is an interface for service-specific details
Expand Down
8 changes: 8 additions & 0 deletions providers/aws/recommendations/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ func (c *Client) GetRecommendationsForService(ctx context.Context, service commo
if successCount == 0 && attempts > 0 && lastErr != nil {
return nil, fmt.Errorf("all (term, payment) variants failed for service %s: %w", service, lastErr)
}
// Enrich each rec with 7-day daily coverage history so the frontend
// can render a per-row sparkline (closes #239 Part 1). SavingsPlans
// are skipped inside AttachDailyUsageHistory (no per-SKU CE coverage
// breakdown available). Errors are logged and skipped per-tuple so a
// single CE failure doesn't suppress the rest of the collection.
if len(allRecs) > 0 {
c.AttachDailyUsageHistory(ctx, allRecs)
}
return allRecs, nil
}

Expand Down
Loading
Loading