diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index bb9e659c..8815e2a4 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -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, renderUsageSparkline, loadColumnFilters, saveColumnFilters, resetColumnFiltersState, spGroupKey, groupSpCellKeys } 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, loadColumnFilters, saveColumnFilters, resetColumnFiltersState, spGroupKey, groupSpCellKeys, formatCapacity } from '../recommendations'; import type { CostPeriod } from '../state'; // Mock the api module @@ -628,6 +628,46 @@ describe('Recommendations Module', () => { expect(list?.innerHTML).toContain('us-east-1'); }); + // #219 end-to-end render regression. Feeds the REAL API shape the fixed + // backend now emits: top-level vcpu/memory_gb (decoded from the nested + // ComputeDetails by buildRecommendationsResponse). Asserts the Capacity + // cell renders "8 vCPU / 32 GB" for a sized compute rec and "—" for a rec + // with neither field. Pre-fix, the backend never emitted these top-level + // fields, so the cell rendered "—" for every row in production while the + // formatCapacity unit tests stayed green on injected values the API never + // produced — this test ties the rendered cell to the real response shape. + test('renders Capacity cell from top-level vcpu/memory_gb (#219)', async () => { + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: {}, + recommendations: [ + { id: 'rec-cap', provider: 'aws', service: 'ec2', + resource_type: 'm5.2xlarge', region: 'us-east-1', + count: 1, term: 1, savings: 100, upfront_cost: 500, + vcpu: 8, memory_gb: 32 }, + { id: 'rec-nocap', provider: 'aws', service: 'rds', + resource_type: 'db.t3.medium', region: 'us-east-1', + count: 1, term: 1, savings: 50, upfront_cost: 200 }, + ], + regions: ['us-east-1'], + }); + + await loadRecommendations(); + + const list = document.getElementById('recommendations-list'); + // The capacity column is the cell immediately after resource_type. + const capCell = (recId: string): string | undefined => { + const checkbox = list?.querySelector(`input[data-rec-id="${recId}"]`); + const row = checkbox?.closest('tr'); + const cells = Array.from(row?.querySelectorAll('td') ?? []); + const rtIdx = cells.findIndex((td) => td.textContent?.includes(recId === 'rec-cap' ? 'm5.2xlarge' : 'db.t3.medium')); + return cells[rtIdx + 1]?.textContent ?? undefined; + }; + + expect(capCell('rec-cap')).toBe('8 vCPU / 32 GB'); + // Rec with no vcpu/memory_gb renders the em-dash placeholder, not "0". + expect(capCell('rec-nocap')).toBe('—'); + }); + test('shows empty-state message when no recommendations', async () => { // Issue #700: zero rows now render an empty with a hint cell // rather than replacing the entire table with a

. The stays @@ -973,15 +1013,15 @@ describe('Recommendations Module', () => { }); }); - test('renders sortable column headers with indicators (Bundle B + #282 + #317: 13 columns)', async () => { + test('renders sortable column headers with indicators (Bundle B + #282 + #317 + #219: 14 columns)', async () => { await loadRecommendations(); const list = document.getElementById('recommendations-list'); - // Bundle B + issue #282 + #317: every data column is sortable. 13 sortable data columns: - // provider, account, service, resource_type, region, count, term, payment, + // Bundle B + issue #282 + #317 + #219: every data column is sortable. 14 sortable data columns: + // provider, account, service, resource_type, capacity, region, count, term, payment, // savings, upfront_cost, monthly_cost, on_demand_monthly, effective_savings_pct. // The leading checkbox column is not sortable. const sortables = list?.querySelectorAll('th.sortable'); - expect(sortables?.length).toBe(13); + expect(sortables?.length).toBe(14); // The default sort is savings desc → that header shows an active ▼. const savingsHeader = list?.querySelector('th[data-sort="savings"]'); expect(savingsHeader?.innerHTML).toContain('active'); @@ -1024,9 +1064,9 @@ describe('Recommendations Module', () => { const rows = document.querySelectorAll('tr.recommendation-row'); const paymentCells = Array.from(rows).map((row) => { - // Payment column is the 9th (0-indexed: 0=checkbox, 1=provider, - // 2=account, 3=service, 4=resource_type, 5=region, 6=count, 7=term, 8=payment) - return row.querySelectorAll('td')[8]?.textContent?.trim() ?? ''; + // Payment column is the 10th (0-indexed: 0=checkbox, 1=provider, + // 2=account, 3=service, 4=resource_type, 5=capacity, 6=region, 7=count, 8=term, 9=payment) + return row.querySelectorAll('td')[9]?.textContent?.trim() ?? ''; }); expect(paymentCells).toContain('All Upfront'); expect(paymentCells).toContain('Partial Upfront'); @@ -1057,7 +1097,7 @@ describe('Recommendations Module', () => { // Only the all-upfront rec should be rendered. expect(rows.length).toBe(1); // Payment cell text should be "All Upfront". - const paymentCell = rows[0]?.querySelectorAll('td')[8]; + const paymentCell = rows[0]?.querySelectorAll('td')[9]; expect(paymentCell?.textContent?.trim()).toBe('All Upfront'); // Restore filter mock so it doesn't leak into subsequent tests. @@ -1997,6 +2037,29 @@ describe('applyColumnFilters', () => { }); expect(out.map(r => r.id)).toEqual(['b']); }); + + test('capacity filter matches formatted "N vCPU / M GB" string', () => { + const recs: LocalRecommendation[] = [ + { ...rec({ id: 'a' }), vcpu: 8, memory_gb: 32 } as LocalRecommendation, + { ...rec({ id: 'b' }), vcpu: 4, memory_gb: 16 } as LocalRecommendation, + { ...rec({ id: 'c' }), vcpu: null, memory_gb: null } as unknown as LocalRecommendation, + ]; + const out = applyColumnFilters(recs, { + capacity: { kind: 'set', values: ['8 vCPU / 32 GB'] }, + }); + expect(out.map(r => r.id)).toEqual(['a']); + }); + + test('capacity filter with empty string matches recs without vcpu/memory_gb', () => { + const recs: LocalRecommendation[] = [ + { ...rec({ id: 'a' }), vcpu: 8, memory_gb: 32 } as LocalRecommendation, + { ...rec({ id: 'b' }), vcpu: null, memory_gb: null } as unknown as LocalRecommendation, + ]; + const out = applyColumnFilters(recs, { + capacity: { kind: 'set', values: [''] }, + }); + expect(out.map(r => r.id)).toEqual(['b']); + }); }); // --------------------------------------------------------------------------- @@ -2118,7 +2181,7 @@ describe('Bundle B: column header filter triggers', () => { const buttons = document.querySelectorAll('th .column-filter-btn[data-column]'); const cols = Array.from(buttons).map((b) => b.dataset['column']); expect(cols.sort()).toEqual( - ['account', 'count', 'effective_savings_pct', 'monthly_cost', 'on_demand_monthly', 'payment', 'provider', 'region', 'resource_type', 'savings', 'service', 'term', 'upfront_cost'].sort(), + ['account', 'capacity', 'count', 'effective_savings_pct', 'monthly_cost', 'on_demand_monthly', 'payment', 'provider', 'region', 'resource_type', 'savings', 'service', 'term', 'upfront_cost'].sort(), ); }); @@ -5368,12 +5431,13 @@ describe('Column visibility (issue #318)', () => { // --- TOGGLEABLE_COLUMNS and COLUMN_DEFS --- describe('COLUMN_DEFS and TOGGLEABLE_COLUMNS', () => { - test('COLUMN_DEFS contains all 14 column ids (13 data + usage_history sparkline)', () => { + test('COLUMN_DEFS contains all 15 column ids (13 data + capacity + usage_history sparkline)', () => { const keys = COLUMN_DEFS.map((c) => c.key); expect(keys).toContain('provider'); expect(keys).toContain('account'); expect(keys).toContain('service'); expect(keys).toContain('resource_type'); + expect(keys).toContain('capacity'); expect(keys).toContain('region'); expect(keys).toContain('count'); expect(keys).toContain('term'); @@ -5385,7 +5449,12 @@ describe('Column visibility (issue #318)', () => { expect(keys).toContain('effective_savings_pct'); // issue #239: usage_history sparkline column added expect(keys).toContain('usage_history'); - expect(COLUMN_DEFS.length).toBe(14); + expect(COLUMN_DEFS.length).toBe(15); + }); + + test('capacity column appears immediately after resource_type', () => { + const keys = COLUMN_DEFS.map((c) => c.key); + expect(keys.indexOf('capacity')).toBe(keys.indexOf('resource_type') + 1); }); test('TOGGLEABLE_COLUMNS excludes fixed identity columns', () => { @@ -5394,7 +5463,8 @@ describe('Column visibility (issue #318)', () => { expect(keys).not.toContain('account'); expect(keys).not.toContain('service'); expect(keys).not.toContain('resource_type'); - // All other 10 columns (9 original + usage_history) should be toggleable. + // All other 11 columns (9 original + capacity + usage_history) should be toggleable. + expect(keys).toContain('capacity'); expect(keys).toContain('region'); expect(keys).toContain('count'); expect(keys).toContain('term'); @@ -5405,11 +5475,48 @@ describe('Column visibility (issue #318)', () => { expect(keys).toContain('on_demand_monthly'); expect(keys).toContain('effective_savings_pct'); expect(keys).toContain('usage_history'); - expect(keys.length).toBe(10); + expect(keys.length).toBe(11); }); }); }); +// --------------------------------------------------------------------------- +// formatCapacity (closes #219) +// --------------------------------------------------------------------------- +describe('formatCapacity', () => { + test('returns formatted string when both vcpu and memory_gb are populated', () => { + expect(formatCapacity(8, 32)).toBe('8 vCPU / 32 GB'); + }); + + test('handles fractional memory without trailing zeros', () => { + expect(formatCapacity(2, 0.5)).toBe('2 vCPU / 0.5 GB'); + }); + + test('returns null when vcpu is null', () => { + expect(formatCapacity(null, 32)).toBeNull(); + }); + + test('returns null when memory_gb is null', () => { + expect(formatCapacity(8, null)).toBeNull(); + }); + + test('returns null when vcpu is undefined', () => { + expect(formatCapacity(undefined, 32)).toBeNull(); + }); + + test('returns null when memory_gb is undefined', () => { + expect(formatCapacity(8, undefined)).toBeNull(); + }); + + test('returns null when vcpu is 0 (unknown)', () => { + expect(formatCapacity(0, 32)).toBeNull(); + }); + + test('returns null when memory_gb is 0 (unknown)', () => { + expect(formatCapacity(8, 0)).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // Issue #494: deterministic group sort on multi-variant cells. // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a8a1a80b..73b98b01 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -154,6 +154,14 @@ export interface Recommendation { // oldest-to-newest). Absent/null when the provider did not populate it // (non-AWS providers or pre-#239 cached rows). usage_history?: number[] | null; + // vcpu / memory_gb surface the compute size of the recommended instance + // type so the Capacity column can render " vCPU / GB" + // (#219). Emitted top-level by the backend (buildRecommendationsResponse + // decodes the nested ComputeDetails and stamps them) only for compute recs + // with a known size; absent for non-compute services, legacy rows, or + // unknown sizes. formatCapacity renders absent/0 as "—". + vcpu?: number | null; + memory_gb?: number | null; } export interface RecommendationFilters { diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 4de23ba0..29201aac 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -1520,6 +1520,7 @@ export const COLUMN_DEFS: readonly ColumnDef[] = [ { key: 'account', label: 'Account', kind: 'categorical' }, { key: 'service', label: 'Service', kind: 'categorical' }, { key: 'resource_type', label: 'Resource Type', kind: 'categorical' }, + { key: 'capacity', label: 'Capacity', kind: 'categorical' }, { key: 'region', label: 'Region', kind: 'categorical' }, { key: 'count', label: 'Count', kind: 'numeric' }, { key: 'term', label: 'Term', kind: 'categorical' }, @@ -1638,12 +1639,24 @@ export function applyColumnFilters( ); } +// formatCapacity renders the VCPU+MemoryGB pair from a ComputeDetails rec. +// Mirrors the Go-side ComputeDetails.GetDetailDescription format: +// " vCPU / GB". Returns null when either field is absent or +// zero so callers can render a dash rather than a misleading "0 vCPU / 0 GB". +export function formatCapacity(vcpu: number | null | undefined, memoryGB: number | null | undefined): string | null { + if (!vcpu || !memoryGB) return null; + // String(n) trims trailing zeros for whole numbers (16, not 16.0) + // and preserves fractional precision (0.5), matching Go's %g format. + return `${vcpu} vCPU / ${String(memoryGB)} GB`; +} + function categoricalCellValue(r: LocalRecommendation, col: state.RecommendationsColumnId): string { switch (col) { case 'provider': return r.provider ?? ''; case 'account': return r.cloud_account_id ?? ''; case 'service': return r.service ?? ''; case 'resource_type': return r.resource_type ?? ''; + case 'capacity': return formatCapacity(r.vcpu, r.memory_gb) ?? ''; case 'region': return r.region ?? ''; case 'term': return r.term == null ? '' : String(r.term); case 'payment': return r.payment ?? ''; @@ -1683,6 +1696,7 @@ function numericCellValue(r: LocalRecommendation, col: state.RecommendationsColu case 'account': case 'service': case 'resource_type': + case 'capacity': case 'region': case 'term': case 'payment': @@ -1725,6 +1739,7 @@ export function displayPrecision(col: state.RecommendationsColumnId, period: Cos case 'account': case 'service': case 'resource_type': + case 'capacity': case 'region': case 'term': case 'payment': @@ -2747,6 +2762,10 @@ function renderColumnCell(key: state.RecommendationsColumnId, rec: LocalRecommen return `${escapeHtml(rec.service)}`; case 'resource_type': return `${escapeHtml(rec.resource_type)}${rec.engine ? ` (${escapeHtml(rec.engine)})` : ''}${ctx.badge}`; + case 'capacity': { + const cap = formatCapacity(rec.vcpu, rec.memory_gb); + return `${cap !== null ? escapeHtml(cap) : '—'}`; + } case 'region': return `${escapeHtml(rec.region)}`; case 'count': diff --git a/frontend/src/state.ts b/frontend/src/state.ts index 681650d1..b7575a86 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -8,7 +8,7 @@ import type { Recommendation } from './api/types'; // Closed enumeration of column ids the per-column filters target. // Typo-safety: misspellings at call sites become compile errors. export type RecommendationsColumnId = - | 'provider' | 'account' | 'service' | 'resource_type' | 'region' + | 'provider' | 'account' | 'service' | 'resource_type' | 'capacity' | 'region' | 'count' | 'term' | 'payment' | 'savings' | 'upfront_cost' | 'monthly_cost' | 'on_demand_monthly' | 'effective_savings_pct' | 'usage_history'; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a920e1f3..131c16fa 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -108,6 +108,11 @@ export interface LocalRecommendation { // 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; + // ComputeDetails fields surfaced by PR #810/#816/#833. + // null = provider catalogue did not return a value (renders as "—", not "0"). + // Absent on non-compute recs (RDS, savings plans, etc.). + vcpu?: number | null; + memory_gb?: number | null; } export interface RecommendationsSummary { diff --git a/internal/api/handler_recommendations.go b/internal/api/handler_recommendations.go index 19c1b209..ab6bfd42 100644 --- a/internal/api/handler_recommendations.go +++ b/internal/api/handler_recommendations.go @@ -8,6 +8,7 @@ import ( "github.com/LeanerCloud/CUDly/internal/auth" "github.com/LeanerCloud/CUDly/internal/config" + "github.com/LeanerCloud/CUDly/pkg/common" "github.com/aws/aws-lambda-go/events" ) @@ -121,12 +122,23 @@ func (h *Handler) filterRecommendationsByAllowedAccounts(ctx context.Context, se func buildRecommendationsResponse(recommendations []config.RecommendationRecord) *RecommendationsResponse { regionSet := make(map[string]struct{}) var totalSavings, totalUpfront float64 - for _, rec := range recommendations { + for i := range recommendations { + rec := &recommendations[i] totalSavings += rec.Savings totalUpfront += rec.UpfrontCost if rec.Region != "" { regionSet[rec.Region] = struct{}{} } + // Surface the compute size (VCPU / MemoryGB) at the top level so + // the frontend Capacity column can render it without parsing the + // opaque Details blob. The canonical values live in the typed + // ComputeDetails nested inside Details; decode them here (the api + // layer may import pkg/common, config may not) and stamp the + // pointers when this is a compute rec with a known size. Non-compute + // recs, legacy rows with empty Details, and decode failures all + // leave the pointers nil, so JSON omits them and the frontend + // renders "—". + stampComputeCapacity(rec) } regions := make([]string, 0, len(regionSet)) @@ -151,6 +163,40 @@ func buildRecommendationsResponse(recommendations []config.RecommendationRecord) } } +// stampComputeCapacity decodes a recommendation's opaque Details blob and, +// when it is a ComputeDetails carrying a known instance size (VCPU > 0 && +// MemoryGB > 0), stamps the top-level VCPU / MemoryGB pointers the frontend +// Capacity column reads (#219). For non-compute services, legacy rows with an +// empty Details, unknown sizes (0), or a decode error it leaves the pointers +// nil so the JSON omits them and the column renders "—" rather than a +// misleading "0 vCPU / 0 GB". +// +// VCPU and MemoryGB must both be present for the pair to be emitted: the +// frontend's formatCapacity treats a missing half as "absent" and renders a +// dash, so emitting one without the other would be inconsistent. +func stampComputeCapacity(rec *config.RecommendationRecord) { + details, err := common.DecodeServiceDetailsFor(rec.Service, rec.Details) + if err != nil || details == nil { + // Decode error or no typed Details for this service: leave the + // capacity fields absent. A malformed Details blob must not break + // the listing; the rest of the row is still valid. + return + } + compute, ok := details.(*common.ComputeDetails) + if !ok || compute == nil { + return + } + if compute.VCPU <= 0 || compute.MemoryGB <= 0 { + // Unknown size (converter didn't wire a catalogue lookup): keep + // both absent so the frontend renders "—". + return + } + vcpu := compute.VCPU + mem := compute.MemoryGB + rec.VCPU = &vcpu + rec.MemoryGB = &mem +} + // getRecommendationsFreshness returns the cache-freshness state (last // successful collection timestamp + most recent collection error) so the // frontend can render a "Data from min ago" indicator and surface a diff --git a/internal/api/handler_recommendations_test.go b/internal/api/handler_recommendations_test.go index cebe0e49..942436dd 100644 --- a/internal/api/handler_recommendations_test.go +++ b/internal/api/handler_recommendations_test.go @@ -2,11 +2,13 @@ package api import ( "context" + "encoding/json" "errors" "testing" "time" "github.com/LeanerCloud/CUDly/internal/config" + "github.com/LeanerCloud/CUDly/pkg/common" "github.com/aws/aws-lambda-go/events" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -354,6 +356,104 @@ func TestGetRecommendations_AccountIDFilter(t *testing.T) { } } +// TestBuildRecommendationsResponse_CapacityFields is the #219 regression +// test. It replicates the REAL API shape: VCPU/MemoryGB live nested inside +// the opaque Details blob (a marshalled common.ComputeDetails), exactly as the +// scheduler persists them via common.MarshalServiceDetails — there are NO +// top-level vcpu/memory_gb fields on the stored record. The pre-fix code never +// decoded Details, so it emitted no top-level vcpu/memory_gb and every +// Capacity cell rendered "—" in production. This asserts the response now +// carries the decoded values at the top level (where the frontend reads them) +// for a compute rec, and omits them for non-compute / unknown-size / empty +// recs so the cell renders "—" rather than a misleading "0 vCPU / 0 GB". +func TestBuildRecommendationsResponse_CapacityFields(t *testing.T) { + // computeDetails marshals a ComputeDetails the same way the scheduler + // does, so the test feeds the exact Details JSON shape the API stores. + computeDetails := func(vcpu int, memGB float64) json.RawMessage { + raw, err := common.MarshalServiceDetails(&common.ComputeDetails{ + InstanceType: "m5.2xlarge", + Platform: "linux", + Tenancy: "default", + VCPU: vcpu, + MemoryGB: memGB, + }) + require.NoError(t, err) + require.NotEmpty(t, raw, "marshalled compute details must not be empty") + return raw + } + + recs := []config.RecommendationRecord{ + { + ID: "rec-compute", + Service: "ec2", + Region: "us-east-1", + Details: computeDetails(8, 32), + }, + { + // Compute rec whose converter didn't resolve a size: VCPU/MemoryGB + // stay at the zero value → fields omitted → cell renders "—". + ID: "rec-compute-unknown-size", + Service: "ec2", + Region: "us-east-1", + Details: computeDetails(0, 0), + }, + { + // Non-compute service (RDS): no VCPU/MemoryGB to surface. + ID: "rec-database", + Service: "rds", + Region: "us-east-1", + Details: json.RawMessage(`{"instance_class":"db.r5.large","engine":"postgres"}`), + }, + { + // Legacy / pre-#453 row with an empty Details blob. + ID: "rec-legacy-empty", + Service: "ec2", + Region: "us-east-1", + }, + } + + resp := buildRecommendationsResponse(recs) + require.NotNil(t, resp) + require.Len(t, resp.Recommendations, 4) + + byID := make(map[string]config.RecommendationRecord, len(resp.Recommendations)) + for _, r := range resp.Recommendations { + byID[r.ID] = r + } + + // Compute rec with a known size → top-level vcpu=8, memory_gb=32. + compute := byID["rec-compute"] + require.NotNil(t, compute.VCPU, "compute rec must carry top-level vcpu") + require.NotNil(t, compute.MemoryGB, "compute rec must carry top-level memory_gb") + assert.Equal(t, 8, *compute.VCPU) + assert.Equal(t, float64(32), *compute.MemoryGB) + + // The serialised JSON must expose them at the TOP LEVEL (not nested under + // details) — this is the exact contract the frontend reads. + blob, err := json.Marshal(compute) + require.NoError(t, err) + var top map[string]json.RawMessage + require.NoError(t, json.Unmarshal(blob, &top)) + assert.JSONEq(t, "8", string(top["vcpu"])) + assert.JSONEq(t, "32", string(top["memory_gb"])) + + // Unknown size, non-compute service, and legacy-empty all omit the fields. + for _, id := range []string{"rec-compute-unknown-size", "rec-database", "rec-legacy-empty"} { + r := byID[id] + assert.Nil(t, r.VCPU, "%s must not carry vcpu", id) + assert.Nil(t, r.MemoryGB, "%s must not carry memory_gb", id) + + b, err := json.Marshal(r) + require.NoError(t, err) + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal(b, &m)) + _, hasVCPU := m["vcpu"] + _, hasMem := m["memory_gb"] + assert.False(t, hasVCPU, "%s JSON must omit vcpu", id) + assert.False(t, hasMem, "%s JSON must omit memory_gb", id) + } +} + func TestConfidenceBucketFor(t *testing.T) { cases := []struct { name string diff --git a/internal/config/types.go b/internal/config/types.go index d0dbc185..612d9503 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -369,6 +369,23 @@ type RecommendationRecord struct { // recommendations JSONB payload — no DDL change needed (closes #239 // Part 1 for AWS). UsageHistory []float64 `json:"usage_history,omitempty" dynamodbav:"usage_history,omitempty"` + // VCPU and MemoryGB surface the compute size of the recommended + // instance type so the frontend's Capacity column can render + // " vCPU / GB" without parsing the opaque Details blob + // (#219). They are NOT persisted: the canonical source is the typed + // ComputeDetails nested inside Details (config must stay free of + // pkg/common imports). The api layer decodes Details via + // common.DecodeServiceDetailsFor in buildRecommendationsResponse and + // stamps these top-level fields on the way out, so the API JSON carries + // them at the top level where the frontend already reads them. + // + // Pointers (not plain int/float64) so "absent / non-compute / unknown + // size" serialises as omitted rather than a misleading 0: the frontend + // renders absent as "—", and a literal 0 would otherwise look like a + // real "0 vCPU / 0 GB" capacity. dynamodbav:"-" because they are + // derived-on-read, never stored. + VCPU *int `json:"vcpu,omitempty" dynamodbav:"-"` + MemoryGB *float64 `json:"memory_gb,omitempty" dynamodbav:"-"` } // PurchaseSuppression records the per-tuple grace window after a bulk