From 2e74e524188f97fd9678c5f4cacb2f0832980e39 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:24:50 +0000 Subject: [PATCH 1/2] fix(frontend): keep purchase-modal Eff. % honest under capacity scaling The capacity clone scaled count, upfront, monthly cost and savings but carried on_demand_cost through unscaled, so at 50% capacity a 47.4% row showed 23.7% at the moment of authorisation. on_demand_cost is a per-rec total like savings, so it now scales with the same ratio. The Totals row re-derived its weighted Eff. % with the AWS reconstruction that effectiveSavingsPct deliberately refuses, so it printed a figure the rows above showed as an em-dash. It now averages the per-row displaySavingsPct values (weighted by each row's on-demand baseline) and shows an em-dash when no selected row has a known %. A stored bulk-purchase capacity is now accepted only as an integer, like every other numeric parse in the module; a hand-edited 33.33 falls back to 100 instead of reaching the scaling and the audited capacity_percent. Refs #136 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/recommendations.test.ts | 79 ++++++++++++++++++- frontend/src/recommendations.ts | 66 +++++++++------- 2 files changed, 115 insertions(+), 30 deletions(-) diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index c62b3342c..717566c57 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, displaySavingsPct, 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 { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, displaySavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS, isHomogeneousSelection, renderUsageSparkline, loadColumnFilters, saveColumnFilters, resetColumnFiltersState, spGroupKey, groupSpCellKeys, formatCapacity, scaleRecForCapacity, weightedDisplaySavingsPct } from '../recommendations'; import type { CostPeriod } from '../state'; // Mock the api module @@ -2107,16 +2107,50 @@ describe('Recommendations Module', () => { }); test('totals row weighted effective % uses on_demand_cost when monthly_cost is null', async () => { + const azureVm = { provider: 'azure' as const, service: 'compute', payment: 'monthly' }; const recs = [ - makeRec('ta', { savings: 100, upfront_cost: 0, monthly_cost: null, on_demand_cost: 200 }), - makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 }), + { ...makeRec('ta', { savings: 100, upfront_cost: 0, monthly_cost: null, on_demand_cost: 200 }), ...azureVm }, + { ...makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 }), ...azureVm }, ]; await openPurchaseModal(recs); + // ta: 100/200; tb (Azure, reconstructed): 100/(50+100+0) -> 200 + 150 baseline. const pctCell = document.getElementById('purchase-modal-total-eff-pct'); expect(pctCell?.textContent).toContain('57.1%'); }); + test('totals Eff. % skips AWS rows whose own Eff. % is an em-dash (#136)', async () => { + // tb is AWS with no on_demand_cost: its row shows an em-dash, so the + // discredited reconstruction must not leak into the total. + const recs = [ + makeRec('ta', { savings: 100, upfront_cost: 0, monthly_cost: null, on_demand_cost: 200 }), + makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 }), + ]; + await openPurchaseModal(recs); + + const rowPcts = Array.from(document.querySelectorAll('#purchase-details tbody tr')) + .map((tr) => tr.querySelector('td:nth-last-child(3)')?.textContent); + expect(rowPcts).toEqual(['50.0%', '—']); + expect(document.getElementById('purchase-modal-total-eff-pct')?.textContent).toBe('50.0%'); + }); + + test('totals Eff. % is an em-dash when no selected row has a known Eff. %', async () => { + await openPurchaseModal([makeRec('tb', { savings: 100, upfront_cost: 0, monthly_cost: 50 })]); + + expect(document.getElementById('purchase-modal-total-eff-pct')?.textContent).toBe('—'); + }); + + test('totals Eff. % uses the provider savings_percentage the rows display', async () => { + const recs = [ + { ...makeRec('ta', { savings: 30, upfront_cost: 0, monthly_cost: 70 }), savings_percentage: 30 }, + { ...makeRec('tb', { savings: 50, upfront_cost: 0, monthly_cost: null, on_demand_cost: 100 }), savings_percentage: 50 }, + ]; + await openPurchaseModal(recs); + + // ta implied baseline 100 at 30%, tb baseline 100 at 50% -> 40%. + expect(document.getElementById('purchase-modal-total-eff-pct')?.textContent).toBe('40.0%'); + }); + test('clearPurchaseModalRecommendations also clears checked state', async () => { await openPurchaseModal([makeRec('c1'), makeRec('c2')]); @@ -3361,6 +3395,19 @@ describe('Bundle B: sticky bottom action box', () => { expect(hint.textContent).toContain('Plans require one provider, service, term, and payment'); }); + test.each([ + [33.33, '100'], + [50, '50'], + [150, '100'], + [0, '100'], + ['abc', '100'], + ])('stored bulk-purchase capacity %p seeds the Capacity input as %s (#136)', async (stored, expected) => { + localStorageMock.getItem.mockImplementation((key: string) => + key === 'cudly.recommendations.bulkPurchase.v1' ? JSON.stringify({ capacity: stored }) : null); + await loadRecommendations(); + expect((document.getElementById('bulk-purchase-capacity') as HTMLInputElement).value).toBe(expected); + }); + test('Capacity input value persists across re-render (mount-once-then-update)', async () => { await loadRecommendations(); const cap = document.getElementById('bulk-purchase-capacity') as HTMLInputElement; @@ -8466,3 +8513,29 @@ describe('Issue #1419: Opportunities Account column shows display name via listA expect(html).toContain('acct-uuid-no-match'); }); }); + +describe('capacity scaling keeps Eff. % invariant (#136)', () => { + const rec = { + id: 'cap', provider: 'azure', service: 'vm', resource_type: 'D2s_v3', region: 'eastus', + count: 4, term: 1, payment: 'no-upfront', savings: 900, upfront_cost: 0, + monthly_cost: 1000, on_demand_cost: 1900, + } as unknown as LocalRecommendation; + + test('on_demand_cost scales with count like savings and upfront', () => { + const scaled = scaleRecForCapacity(rec, 50)!; + expect(scaled.count).toBe(2); + expect(scaled.savings).toBe(450); + expect(scaled.on_demand_cost).toBe(950); + }); + + test.each([25, 50, 75, 100])('Eff. %% at %i%% capacity equals the unscaled 47.4%%', (capacity) => { + const scaled = scaleRecForCapacity(rec, capacity)!; + expect(displaySavingsPct(scaled)!.toFixed(1)).toBe('47.4'); + expect(weightedDisplaySavingsPct([scaled])!.toFixed(1)).toBe('47.4'); + }); + + test('an absent on_demand_cost stays absent', () => { + const scaled = scaleRecForCapacity({ ...rec, on_demand_cost: null } as LocalRecommendation, 50)!; + expect(scaled.on_demand_cost).toBeNull(); + }); +}); diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 307ff2d7a..ee7dd76a5 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -1626,6 +1626,30 @@ export function displaySavingsPct(r: LocalRecommendation): number | null { return effectiveSavingsPct(r); } +/** + * On-demand-weighted average of the per-row Eff. % the purchase modal shows + * (displaySavingsPct), so the Totals row can never print a figure the rows + * refused to: a row that renders an em-dash (e.g. AWS without on_demand_cost, + * #323) contributes nothing. Each row is weighted by its on-demand baseline: + * on_demand_cost when known, otherwise the baseline its own % implies + * (savings / pct). Returns null when no row contributes. + */ +export function weightedDisplaySavingsPct(recs: LocalRecommendation[]): number | null { + let num = 0; + let den = 0; + for (const r of recs) { + const pct = displaySavingsPct(r); + if (pct === null) continue; + const base = r.on_demand_cost != null && r.on_demand_cost > 0 + ? r.on_demand_cost + : pct !== 0 ? (r.savings * 100) / pct : null; + if (base === null || !(base > 0)) continue; + num += (pct * base) / 100; + den += base; + } + return den > 0 ? (num / den) * 100 : null; +} + /** * Returns the provider-reported on-demand monthly cost (`r.on_demand_cost`) * directly. Issue #330 — earlier behaviour reconstructed the value from @@ -3625,9 +3649,13 @@ function loadBulkPurchaseState(): BulkPurchaseToolbarState { // legacy `term` or `payment` from older localStorage values at runtime. // Payment is seeded from GlobalConfig only (issue #282 drops the toolbar // dropdown; the field is internal-only, not persisted). + // Integers only, like every other numeric parse here: a hand-edited + // 33.33 would otherwise reach Math.floor scaling and the audited + // capacity_percent as a value no UI control can produce (#136). + const capacity = Number(parsed.capacity); return { payment: cachedGlobalDefaultPayment as BulkPurchasePayment, - capacity: Math.max(1, Math.min(100, Number(parsed.capacity) || 100)), + capacity: Number.isInteger(capacity) && capacity > 0 ? Math.min(100, capacity) : 100, }; } catch { return { ...defaultBulkPurchaseState }; @@ -4113,7 +4141,7 @@ async function openCreatePlanFromBottomBox(snapshot: LocalRecommendation[]): Pro // Extracted from handleBulkPurchaseClick's scaling loop (issue #1903) so // pricedCellVariant can apply the same scaling to a sibling variant swapped // in on a Term/Payment change. -function scaleRecForCapacity(r: LocalRecommendation, capacityPercent: number): LocalRecommendation | null { +export function scaleRecForCapacity(r: LocalRecommendation, capacityPercent: number): LocalRecommendation | null { const newCount = Math.floor((r.count * capacityPercent) / 100); if (newCount <= 0) return null; const ratio = r.count > 0 ? newCount / r.count : 1; @@ -4126,6 +4154,9 @@ function scaleRecForCapacity(r: LocalRecommendation, capacityPercent: number): L upfront_cost: r.upfront_cost * ratio, monthly_cost: r.monthly_cost != null ? r.monthly_cost * ratio : null, savings: r.savings * ratio, + // on_demand_cost is a per-rec total like savings, so it scales too; + // leaving it unscaled halved the Eff. % at 50% capacity (#136). + on_demand_cost: r.on_demand_cost != null ? r.on_demand_cost * ratio : r.on_demand_cost, }; } @@ -5392,9 +5423,7 @@ function updatePurchaseModalTotals(selectAllCb: HTMLInputElement): void { let totalUpfront = 0; let totalMonthlyCost = 0; let totalEffSavings = 0; - // Weighted-average effective %: sum effective savings / sum on-demand monthly. - let weightedEffSavingsNum = 0; // numerator: sum of effectiveMonthlySavings per checked rec - let weightedEffSavingsDen = 0; // denominator: sum of on-demand monthly per checked rec + const checkedRecs: LocalRecommendation[] = []; let hasMonthlyCostData = false; for (const idx of checkedPurchaseIndices) { @@ -5407,26 +5436,10 @@ function updatePurchaseModalTotals(selectAllCb: HTMLInputElement): void { totalMonthlyCost += monthlyCost; hasMonthlyCostData = true; } - // Weighted-average effective % denominator should match effectiveSavingsPct: - // prefer provider-supplied on_demand_cost when present, otherwise fall back - // to the reconstructed monthly_cost + savings + amortized baseline. - // Include rows that only have on_demand_cost so they are not skipped. - if (rec.term) { - const amortized = rec.upfront_cost / (rec.term * 12); - const effSav = effectiveMonthlySavings(rec); - const hasOnDemand = rec.on_demand_cost != null && rec.on_demand_cost > 0; - const onDemand = hasOnDemand - ? rec.on_demand_cost - : monthlyCost != null - ? monthlyCost + rec.savings + amortized - : null; - if (onDemand != null && onDemand > 0) { - weightedEffSavingsNum += effSav; - weightedEffSavingsDen += onDemand; - } - } + checkedRecs.push(rec); totalEffSavings += effectiveMonthlySavings(rec); } + const avgEffPct = weightedDisplaySavingsPct(checkedRecs); // Rebuild the totals row DOM. Clear first. if (totalsRow) { @@ -5489,10 +5502,9 @@ function updatePurchaseModalTotals(selectAllCb: HTMLInputElement): void { const tdEffPct = document.createElement('td'); tdEffPct.id = 'purchase-modal-total-eff-pct'; const effPctStrong = document.createElement('strong'); - if (weightedEffSavingsDen > 0) { - const avgPct = (weightedEffSavingsNum / weightedEffSavingsDen) * 100; - effPctStrong.textContent = avgPct.toFixed(1) + '%'; - if (avgPct < 0) tdEffPct.className = 'effective-pct-negative'; + if (avgEffPct !== null) { + effPctStrong.textContent = avgEffPct.toFixed(1) + '%'; + if (avgEffPct < 0) tdEffPct.className = 'effective-pct-negative'; } else { effPctStrong.textContent = '—'; } From bdaa21f217ae76af5a2761f96ad8870ea601ff2f Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:24:50 +0000 Subject: [PATCH 2/2] fix(frontend): stop one bad field from blanking reshape and API-key tables - Reshape recommendations: an omitted utilization_percent made toFixed throw inside the row map, replacing the panel with "Cannot read properties of undefined"; it also styled unknown as red. Unknown now renders a neutral em-dash, mirroring the active-RI table. - API keys: new Date(bad).toISOString() threw a RangeError while building the rows, so one malformed last_used_at hid every key, Revoke buttons included. The title falls back to empty and the cell to "--". - Plans: Progress rendered "0/1 steps" when total_steps was missing; it now shows "--", like the Term cell beside it. - index.html: the API Docs link gets rel="noopener noreferrer". - riexchange.ts: drop the unused MODE_VALUES and the void that hid it from lint (A12-068). Refs #136 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/apikeys.test.ts | 18 ++++++++++++++++++ frontend/src/__tests__/html.test.ts | 8 ++++++++ frontend/src/__tests__/plans.test.ts | 21 +++++++++++++++++++++ frontend/src/__tests__/riexchange.test.ts | 21 +++++++++++++++++++++ frontend/src/apikeys.ts | 12 +++++++++++- frontend/src/index.html | 2 +- frontend/src/plans.ts | 9 ++++++++- frontend/src/riexchange.ts | 17 +++++++++-------- 8 files changed, 97 insertions(+), 11 deletions(-) diff --git a/frontend/src/__tests__/apikeys.test.ts b/frontend/src/__tests__/apikeys.test.ts index ecf634f16..03bf640bf 100644 --- a/frontend/src/__tests__/apikeys.test.ts +++ b/frontend/src/__tests__/apikeys.test.ts @@ -1221,6 +1221,24 @@ describe('API Keys Module', () => { expect(container?.innerHTML).toContain('Never'); }); + test('one malformed last_used_at degrades to one cell instead of blanking the table (#136)', async () => { + const mockKeys = [ + { id: 'key-bad', name: 'Bad Timestamp', key_prefix: 'bad111', is_active: true, + created_at: '2024-01-15T10:00:00Z', last_used_at: 'not-a-date' }, + { id: 'key-ok', name: 'Leaked Key', key_prefix: 'ok2222', is_active: true, + created_at: '2024-01-15T10:00:00Z', last_used_at: '2024-01-16T15:30:00Z' }, + ]; + + (api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: mockKeys }); + await loadApiKeys(); + + const container = document.getElementById('apikeys-list'); + const rows = container?.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(2); + expect(rows?.[0]?.textContent).toContain('--'); + expect(container?.querySelectorAll('.revoke-key-btn')).toHaveLength(2); + }); + // #492: populated last_used_at renders relative time with ISO title for hover test('renders relative time with ISO title for populated last_used_at (issue #492)', async () => { const recentTs = new Date(Date.now() - 2 * 3600 * 1000).toISOString(); // 2 hours ago diff --git a/frontend/src/__tests__/html.test.ts b/frontend/src/__tests__/html.test.ts index 463cc4889..e828864ed 100644 --- a/frontend/src/__tests__/html.test.ts +++ b/frontend/src/__tests__/html.test.ts @@ -791,5 +791,13 @@ describe('HTML Structure', () => { // Inert until updateUserUI shows it with the signed-in email. expect(el?.hasAttribute('hidden')).toBe(true); }); + + test('every target="_blank" link sets rel="noopener noreferrer" (#136)', () => { + const links = Array.from(document.querySelectorAll('a[target="_blank"]')); + expect(links.length).toBeGreaterThan(0); + for (const link of links) { + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + } + }); }); }); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index e9591d981..eaf60718a 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -249,6 +249,27 @@ describe('Plans Module', () => { expect(list?.innerHTML).toContain('ec2'); }); + test.each([ + [{ current_step: 2, total_steps: 4 }, '2/4 steps'], + [{ current_step: 0, total_steps: 0 }, '--'], + [{ current_step: 0 }, '--'], + ])('ramp progress %j renders %s instead of a fabricated 0/1 (#136)', async (steps, expected) => { + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ + id: 'plan-1', name: 'Ramp Plan', enabled: true, auto_purchase: true, + services: { ec2: { provider: 'aws', service: 'ec2', enabled: true, term: 1, payment: 'all-upfront', coverage: 80 } }, + ramp_schedule: { type: 'weekly', percent_per_step: 25, step_interval_days: 7, ...steps }, + }], + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadPlans(); + + const progress = Array.from(document.querySelectorAll('#plans-list .plan-detail')) + .find((el) => el.querySelector('.plan-detail-label')?.textContent === 'Progress'); + expect(progress?.querySelector('.plan-detail-value')?.textContent).toBe(expected); + }); + test('multi-SP plan summary lists every plan-type covered (issue #131)', async () => { // Pre-fix: extractPlanInfo took serviceValues[0], so a plan // covering both Compute SP and SageMaker SP rendered only diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index e9f42d41c..7ecf3b928 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -727,6 +727,27 @@ describe('reshape recommendations table', () => { expect(chips[1]?.textContent).toContain('$35.00/mo'); }); + it('renders an unknown utilization as a neutral em-dash instead of throwing (#136)', async () => { + const mockGet = api.getReshapeRecommendations as jest.Mock; + const { utilization_percent: _omit, ...withoutUtil } = baseRec; + mockGet.mockResolvedValueOnce({ + recommendations: [withoutUtil, { ...baseRec, source_ri_id: 'ri-def', utilization_percent: 97 }], + recs_staleness: '', + recs_collected_at: null, + }); + + await loadReshapeRecommendations(); + + const rows = tableContainer.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(2); + const unknown = rows[0]!.querySelectorAll('td')[4]!; + expect(unknown.textContent).toBe('—'); + expect(unknown.className).toBe(''); + const known = rows[1]!.querySelectorAll('td')[4]!; + expect(known.textContent).toBe('97.0%'); + expect(known.className).toBe('util-green'); + }); + 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({ recommendations: [{ ...baseRec }], recs_staleness: '', recs_collected_at: null }); // no alternative_targets diff --git a/frontend/src/apikeys.ts b/frontend/src/apikeys.ts index 31cd71265..4112be2b3 100644 --- a/frontend/src/apikeys.ts +++ b/frontend/src/apikeys.ts @@ -67,6 +67,16 @@ export async function loadApiKeys(): Promise { * failed fetch. Uses textContent (no innerHTML) to stay XSS-safe and * matches the patterns used by other modules (e.g. dashboard.ts). */ +/** + * ISO timestamp for a title attribute, or '' for an unparseable value: + * toISOString throws a RangeError on an Invalid Date, and that throw inside + * the row map blanked the whole keys table, Revoke buttons included (#136). + */ +function isoOrEmpty(value: string): string { + const d = new Date(value); + return isNaN(d.getTime()) ? '' : d.toISOString(); +} + function renderApiKeysListError(container: HTMLElement | null, message: string): void { if (!container) return; container.replaceChildren(); @@ -129,7 +139,7 @@ export function renderApiKeysList(): void { ${escapeHtml(key.key_prefix)}... ${statusText} ${formatDateTime(key.created_at)} - ${key.last_used_at ? `${escapeHtml(formatRelativeTime(key.last_used_at))}` : 'Never'} + ${key.last_used_at ? `${escapeHtml(formatRelativeTime(key.last_used_at) || '--')}` : 'Never'} ${countWindow} ${countTotal} ${key.expires_at ? formatDateTime(key.expires_at) : 'Never'} diff --git a/frontend/src/index.html b/frontend/src/index.html index ebbbdd24c..29648b936 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -43,7 +43,7 @@

CUDly

diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index e9365d926..f786e995e 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -1090,6 +1090,13 @@ async function loadPlanAccountNames(planId: string, cardEl: Element): Promise
Progress - ${rampSchedule.current_step || 0}/${rampSchedule.total_steps || 1} steps + ${formatRampProgress(rampSchedule)}
${showNextDate ? `
diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index 4b0855f7b..7c5d67b39 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -79,12 +79,6 @@ async function resolveScope(): Promise<{ provider: Provider | ''; accountID?: st // Mode label mapping — single source of truth const MODE_LABELS: Record = { manual: "Manual Approval", auto: "Fully Automated" }; -const MODE_VALUES: Record = Object.fromEntries( - Object.entries(MODE_LABELS).map(([k, v]) => [v, k]) -); - -// Suppress unused variable warning — MODE_VALUES is used in saveAutomationSettings -void MODE_VALUES; /** * Load the RI Exchange tab — called when tab is activated and whenever the @@ -1405,7 +1399,14 @@ function renderRecommendations(container: HTMLElement): void { ${visibleWithIdx.map(({ rec, idx }) => { - const utilClass = rec.utilization_percent >= 95 ? 'util-green' : rec.utilization_percent >= 70 ? 'util-yellow' : 'util-red'; + // The API can omit utilization_percent (the filter extractor + // already maps it to NaN). Unknown renders as a neutral em-dash: + // calling toFixed on it threw and left the panel on its skeleton, + // and undefined >= 70 is false, which painted unknown as red. + const utilPct = typeof rec.utilization_percent === 'number' && Number.isFinite(rec.utilization_percent) + ? rec.utilization_percent + : null; + const utilClass = utilPct === null ? '' : utilPct >= 95 ? 'util-green' : utilPct >= 70 ? 'util-yellow' : 'util-red'; const altCell = renderAlternativesCell(rec.alternative_targets); return ` @@ -1413,7 +1414,7 @@ function renderRecommendations(container: HTMLElement): void { ${rec.source_count}x ${escapeHtml(rec.source_instance_type)} ${rec.target_count}x ${escapeHtml(rec.target_instance_type)} ${altCell} - ${rec.utilization_percent.toFixed(1)}% + ${utilPct === null ? '—' : `${utilPct.toFixed(1)}%`} ${rec.normalized_used.toFixed(1)} / ${rec.normalized_purchased.toFixed(1)} ${escapeHtml(rec.reason)} ${canExchange ? `