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
8 changes: 5 additions & 3 deletions frontend/src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2014,7 +2014,8 @@ describe('Dashboard Module', () => {
// ec2: 40 current + 60 lowest option + 200 upside = 300; rds: 50. Total 350.
expect(label).toContain('2 services');
expect(label).toContain('$350 in total');
expect(label).toContain('Largest: ec2 at $300');
// #1649: the text alternative uses the same names as the axis.
expect(label).toContain('Largest: EC2 at $300');
});

test('lists every layer value per service in a "View as table" disclosure', () => {
Expand All @@ -2026,9 +2027,10 @@ describe('Dashboard Module', () => {
expect(document.querySelector('#savings-by-service-table summary')?.textContent).toBe('View as table');
const headers = Array.from(document.querySelectorAll('#savings-by-service-table thead th')).map((th) => th.textContent);
expect(headers).toEqual(['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total']);
// #1649: display names, matching the chart axis.
expect(tableRows()).toEqual([
['ec2', '$40', '$60', '$200', '$300'],
['rds', '$0', '$50', '$0', '$50'],
['EC2', '$40', '$60', '$200', '$300'],
['RDS', '$0', '$50', '$0', '$50'],
]);
});

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/__tests__/plans-column-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,8 @@ describe('Planned Purchases column filters (issue #166 follow-up)', () => {
const survivor = document.querySelector(
'#planned-purchases-list tbody tr.planned-purchase-row',
);
expect(survivor?.innerHTML).toContain('rds');
// #1683: the Service cell shows the display name.
expect(survivor?.innerHTML).toContain('RDS');
});

test('invalid numeric expression surfaces the lib error inline (no filter applied)', async () => {
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/__tests__/plans-service-ids.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,21 @@ describe('plan modal service ids (#608)', () => {
expect(showToast).not.toHaveBeenCalled();
});

// The card shows display names (#1683); the edit flow must still select the
// option by slug. Before, the card's "Compute SP" label was also used as the
// id, so a Savings Plans plan opened with "no longer supported".
test.each([
['aws', 'ec2'],
['aws', 'rds'],
['aws', 'savings-plans-compute'],
])('edit flow of a saved %s plan with service %s selects its option without an error', async (provider, saved) => {
await editSavedPlan(provider, saved);

const select = document.getElementById('plan-service') as HTMLSelectElement;
expect(select.selectedOptions[0]!.value).toBe(saved);
expect(showToast).not.toHaveBeenCalled();
});

test.each([
['azure', 'cosmosdb'],
['gcp', 'cloudstorage'],
Expand Down
30 changes: 27 additions & 3 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ describe('Plans Module', () => {

const list = document.getElementById('plans-list');
expect(list?.innerHTML).toContain('Test Plan');
expect(list?.innerHTML).toContain('ec2');
// #1683: the Service field shows the display name.
expect(list?.innerHTML).toContain('EC2');
});

test.each([
Expand Down Expand Up @@ -341,8 +342,11 @@ describe('Plans Module', () => {
await loadPlans();

const list = document.getElementById('plans-list');
// The Service field shows "ec2" — the slug pass-through case.
expect(list?.innerHTML).toContain('ec2');
// #1683: the Service field shows the display name, not the slug.
const values = Array.from(list?.querySelectorAll('.plan-detail') ?? [])
.filter((d) => d.querySelector('.plan-detail-label')?.textContent === 'Service')
.map((d) => d.querySelector('.plan-detail-value')?.textContent);
expect(values).toEqual(['EC2']);
});

test('shows empty message when no plans', async () => {
Expand Down Expand Up @@ -730,6 +734,26 @@ describe('Plans Module', () => {
expect(container?.innerHTML).toContain('us-east-1');
});

// #1683: the Service cell shows the display name, not the slug.
test('shows the service display name in the planned purchases table', async () => {
(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({
purchases: [{
id: 'purchase-rds', plan_id: 'plan-1', plan_name: 'RDS plan', scheduled_date: '2024-02-20',
provider: 'aws', service: 'rds', resource_type: 'db.t3.micro', region: 'us-east-1', count: 1,
term: 1, payment: 'no-upfront', upfront_cost: 0, estimated_savings: 5, status: 'pending',
step_number: 1, total_steps: 1,
}],
});

await loadPlans();

const cells = Array.from(document.querySelectorAll('#planned-purchases-list tbody td'))
.map((td) => td.textContent?.trim());
expect(cells).toContain('RDS');
expect(cells).not.toContain('rds');
});

test('labels a step with no recommendations instead of rendering zeros (#609)', async () => {
(api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,15 +418,16 @@ describe('Recommendations Module', () => {
await loadRecommendations();

expect(api.getRecommendations).toHaveBeenLastCalledWith(expect.objectContaining({ service: 'ec2' }));
expect(scopeLine()).toBe('Filtered: 2 recommendations (Service: ec2)');
// #1683: the pushed-down service filter is named like the Service column.
expect(scopeLine()).toBe('Filtered: 2 recommendations (Service: EC2)');

// A further client-side filter is counted, not named.
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({
service: { kind: 'set', values: ['ec2'] },
term: { kind: 'set', values: ['1'] },
});
document.querySelector<HTMLTableCellElement>('th[data-sort="service"]')?.click();
expect(scopeLine()).toBe('Filtered: 2 recommendations (Service: ec2, +1 more filter)');
expect(scopeLine()).toBe('Filtered: 2 recommendations (Service: EC2, +1 more filter)');
} finally {
// Reload unfiltered so the pushed-down scope doesn't leak into later tests.
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({});
Expand Down Expand Up @@ -2854,6 +2855,16 @@ describe('Bundle B: column header filter triggers', () => {
expect(values.sort()).toEqual(['aws', 'azure']);
});

// #1683: the Service filter lists display names but still filters on slugs.
test('service popover shows display names and keeps slug values', async () => {
await loadRecommendations();
document.querySelector<HTMLButtonElement>('th .column-filter-btn[data-column="service"]')?.click();
const items = Array.from(
document.querySelectorAll<HTMLLabelElement>('.column-filter-popover .column-filter-item'),
).map((label) => [label.querySelector('input')?.dataset['value'], label.textContent]);
expect(items).toContainEqual(['ec2', 'EC2']);
});

test('service popover includes SageMaker when the loaded rec set has it', async () => {
const sagemakerRecs = [
...sampleRecs,
Expand Down Expand Up @@ -3294,11 +3305,12 @@ describe('Bundle B: column header filter triggers', () => {

test('Azure SP rows (service="savingsplans") appear in the rendered table', async () => {
await loadRecommendations();
// The table should contain a row with service badge "savingsplans".
// The row's service badge shows the display name (#1683), not the slug.
const serviceBadges = Array.from(
document.querySelectorAll<HTMLElement>('td .service-badge'),
).map((el) => el.textContent ?? '');
expect(serviceBadges).toContain('savingsplans');
expect(serviceBadges).toContain('Savings Plans');
expect(serviceBadges).not.toContain('savingsplans');
});

test('service popover renders All Savings Plans toggle when Azure SP + AWS SP slugs are present', async () => {
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/__tests__/service-labels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { serviceLabel } from '../lib/service-labels';

// Issues #1649 / #1683: one display name per service slug for every surface.
describe('serviceLabel', () => {
test.each([
['ec2', 'EC2'],
['rds', 'RDS'],
['relational-db', 'Relational DB'],
['data-warehouse', 'Data Warehouse'],
['compute', 'Compute'],
['savings-plans-compute', 'Compute Savings Plans'],
['savingsplans', 'Savings Plans'],
])('%s -> %s', (slug, label) => {
expect(serviceLabel(slug)).toBe(label);
});

test('compact form abbreviates Savings Plans', () => {
expect(serviceLabel('savings-plans-ec2instance', true)).toBe('EC2 Instance SP');
expect(serviceLabel('rds', true)).toBe('RDS');
});

test('unknown slugs pass through unchanged', () => {
expect(serviceLabel('some-new-service')).toBe('some-new-service');
});
});
38 changes: 6 additions & 32 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { canAccess } from './permissions';
import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale, startRecommendationsRefresh } from './recommendations';
import { getRecommendationsFreshness } from './api/recommendations';
import { buildFirstRunEmptyState, isScopeFiltered } from './first-run-empty-state';
import { serviceLabel } from './lib/service-labels';
import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton';
import { buildErrorCallout } from './lib/error-callout';
import { filterByEnabledProviders } from './lib/enabled-providers';
Expand Down Expand Up @@ -50,33 +51,6 @@ function segmentGap() {
return { borderColor: surfaceColor(), borderWidth: { top: 0, right: 2, bottom: 0, left: 0 } } as const;
}

// Display names for the per-service chart's axis and tooltips. The chart
// groups by service slug across providers, so shared slugs (compute, cache,
// relational-db) get a provider-neutral name. Unknown slugs pass through.
const SERVICE_CHART_LABELS: Readonly<Record<string, string>> = {
ec2: 'EC2',
rds: 'RDS',
elasticache: 'ElastiCache',
opensearch: 'OpenSearch',
memorydb: 'MemoryDB',
redshift: 'Redshift',
'savings-plans-compute': 'Compute Savings Plans',
'savings-plans-ec2instance': 'EC2 Instance Savings Plans',
'savings-plans-sagemaker': 'SageMaker Savings Plans',
'savings-plans-database': 'Database Savings Plans',
compute: 'Compute',
'relational-db': 'Relational DB',
cache: 'Cache',
search: 'Search',
nosql: 'NoSQL',
};

export function serviceChartLabel(slug: string, compact = false): string {
const label = SERVICE_CHART_LABELS[slug] ?? slug;
// Narrow charts abbreviate "Savings Plans" the way plan cards do ("Compute SP").
return compact ? label.replace(/ Savings Plans$/, ' SP') : label;
}

// Below this chart width the category labels use the compact names.
const SERVICE_CHART_COMPACT_PX = 560;

Expand Down Expand Up @@ -1064,12 +1038,12 @@ export function renderSavingsByService(
const top = positive[0]!;
setChartSummary(canvas,
`Stacked bar chart of monthly savings for ${positive.length} service${positive.length === 1 ? '' : 's'}, `
+ `${formatCurrency(totalSavings)} in total. Largest: ${top.svc} at ${formatCurrency(top.visibleTotal)}. `
+ `${formatCurrency(totalSavings)} in total. Largest: ${serviceLabel(top.svc)} at ${formatCurrency(top.visibleTotal)}. `
+ 'Layers: Current / Committed, Lowest option, Upside. Values are in the table below the chart.');
renderChartTable(canvas, SAVINGS_BY_SERVICE_TABLE_ID, {
caption: 'Potential savings range per service (monthly)',
columns: ['Service', 'Current / Committed', 'Lowest option', 'Upside', 'Total'],
rows: positive.map(e => [e.svc, formatCurrency(e.current), formatCurrency(e.lowestOption),
rows: positive.map(e => [serviceLabel(e.svc), formatCurrency(e.current), formatCurrency(e.lowestOption),
formatCurrency(e.upside), formatCurrency(e.visibleTotal)]),
});

Expand Down Expand Up @@ -1127,7 +1101,7 @@ export function renderSavingsByService(
},
tooltip: {
callbacks: {
title: (items) => serviceChartLabel(items[0]?.label ?? ''),
title: (items) => serviceLabel(items[0]?.label ?? ''),
label: (ctx) => {
const svc = ctx.label ?? '';
const s = stats.get(svc);
Expand All @@ -1148,7 +1122,7 @@ export function renderSavingsByService(
const total = current + lowestOption + upside;
const pct = totalSavings > 0 ? ((total / totalSavings) * 100).toFixed(1) : '0.0';
const lines = [
`Service: ${serviceChartLabel(svc)}`,
`Service: ${serviceLabel(svc)}`,
`Total: ${formatCurrency(total)} (${pct}% of all services)`,
`Current / Committed: ${formatCurrency(current)}`,
`Lowest option: ${formatCurrency(lowestOption)}`,
Expand Down Expand Up @@ -1179,7 +1153,7 @@ export function renderSavingsByService(
ticks: {
autoSkip: false,
callback(value) {
return serviceChartLabel(this.getLabelForValue(value as number), this.chart.width < SERVICE_CHART_COMPACT_PX);
return serviceLabel(this.getLabelForValue(value as number), this.chart.width < SERVICE_CHART_COMPACT_PX);
},
},
},
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/purchase-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const SAVINGS_PLANS_BUCKET_KEY = 'savings-plans';

// Pretty short label per SP plan type used inside the mixed-bucket
// label (e.g. "Savings Plans (Compute + SageMaker)"). Mirrors the
// abbreviations in plans.ts:planServiceLabel without coupling to it.
// compact names in lib/service-labels.ts:serviceLabel without coupling to it.
const SP_SHORT_LABEL: Record<string, string> = {
'savings-plans-compute': 'Compute',
'savings-plans-ec2instance': 'EC2 Instance',
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/lib/service-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Display names for service slugs, shared by every page that shows a service
// (issues #1649, #1683). Slugs shared across providers (compute, cache,
// relational-db) get a provider-neutral name; the Provider column next to them
// carries the provider. Unknown slugs pass through unchanged. A Go test
// (internal/config/service_labels_frontend_test.go) checks every library
// ServiceType has an entry here.
const SERVICE_LABELS: Readonly<Record<string, string>> = {
ec2: 'EC2',
rds: 'RDS',
elasticache: 'ElastiCache',
opensearch: 'OpenSearch',
memorydb: 'MemoryDB',
redshift: 'Redshift',
'savings-plans-compute': 'Compute Savings Plans',
'savings-plans-ec2instance': 'EC2 Instance Savings Plans',
'savings-plans-sagemaker': 'SageMaker Savings Plans',
'savings-plans-database': 'Database Savings Plans',
savingsplans: 'Savings Plans',
compute: 'Compute',
'relational-db': 'Relational DB',
cache: 'Cache',
search: 'Search',
nosql: 'NoSQL',
'data-warehouse': 'Data Warehouse',
storage: 'Storage',
commitments: 'Commitments',
other: 'Other',
};

/** Display name for a service slug; `compact` abbreviates "Savings Plans" to "SP". */
export function serviceLabel(slug: string, compact = false): string {
const label = SERVICE_LABELS[slug] ?? slug;
return compact ? label.replace(/ Savings Plans$/, ' SP') : label;
}
32 changes: 13 additions & 19 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { suggestedPurchaseStartDate } from './lib/calendar-date';
import { badgeHtml, statusBadgeHtml } from './lib/status-badge';
import { isPastScheduledDate } from './lib/purchase-row-guards';
import { serviceLabel } from './lib/service-labels';
import { mountPlanEditorPanel } from './plan-editor-panel';

// pendingPlanRecommendations holds the resolved plan target captured at
Expand Down Expand Up @@ -469,7 +470,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
</td>
<td>${formatDate(purchase.scheduled_date)}</td>
<td>${providerBadgeHtml(purchase.provider)}</td>
<td>${escapeHtml(purchase.service)}</td>
<td>${escapeHtml(serviceLabel(purchase.service, true))}</td>
<td>${resourceCell}</td>
<td class="num">${countCell}</td>
<td>${termCell}</td>
Expand Down Expand Up @@ -724,16 +725,6 @@ const LEGACY_PLAN_SERVICE_IDS: Readonly<Record<string, string>> = {
'gcp/memorystore': 'cache',
};

function planServiceLabel(slug: string): string {
switch (slug) {
case 'savings-plans-compute': return 'Compute SP';
case 'savings-plans-ec2instance': return 'EC2 Instance SP';
case 'savings-plans-sagemaker': return 'SageMaker SP';
case 'savings-plans-database': return 'Database SP';
default: return slug;
}
}

// Extract provider/service info from plan's services map.
//
// `service` is now a comma-separated list of all services covered by
Expand All @@ -747,28 +738,31 @@ function planServiceLabel(slug: string): string {
// are plan-level today, not per-service, so picking any entry is
// correct. If the model ever differentiates per service, this needs
// to render the same way.
function extractPlanInfo(plan: BackendPlan): { provider: string | null; service: string; term: number | null; coverage: number | null } {
//
// `service` holds the raw slugs (the edit flow selects the service option by
// it); `serviceDisplay` holds the display names for the card (#1683).
function extractPlanInfo(plan: BackendPlan): { provider: string | null; service: string; serviceDisplay: string; term: number | null; coverage: number | null } {
const services = plan.services || {};
const serviceValues = Object.values(services);
const firstService = serviceValues[0];
if (firstService) {
const service = serviceValues.length === 0
? '—'
: serviceValues
.map(s => planServiceLabel(s.service || '—'))
.join(', ');
const service = serviceValues.map(s => s.service || '—').join(', ');
const serviceDisplay = serviceValues
.map(s => (s.service ? serviceLabel(s.service, true) : '—'))
.join(', ');
// H-4: never fabricate provider/term/coverage when absent.
// Return null so callers can surface NO_VALUE/'Unknown' in the display
// layer rather than silently committing to aws/3yr/80% defaults.
return {
provider: firstService.provider || null,
service,
serviceDisplay,
term: firstService.term || null,
coverage: firstService.coverage ?? null
};
}
// No services at all: all fields are unknown.
return { provider: null, service: '—', term: null, coverage: null };
return { provider: null, service: '—', serviceDisplay: '—', term: null, coverage: null };
}

// Returns true when the plan's next_execution_date is strictly before today.
Expand Down Expand Up @@ -866,7 +860,7 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan
</div>
<div class="plan-detail">
<span class="plan-detail-label">Service</span>
<span class="plan-detail-value">${escapeHtml(info.service)}</span>
<span class="plan-detail-value">${escapeHtml(info.serviceDisplay)}</span>
</div>
<div class="plan-detail">
<span class="plan-detail-label">Term</span>
Expand Down
Loading
Loading