From a1a2d32284a98ab6581cc049b8184b5f75b27fca Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Sat, 24 Oct 2026 01:49:38 +0000 Subject: [PATCH] fix(frontend): save Azure/GCP settings under the service ids recommendations carry Recommendations store the provider's common.ServiceType slug (compute, relational-db, nosql, cache, search), and the scheduler (config.ResolveAccountConfigsForRecs) and the purchase modal look up service configs and per-account overrides by that exact (provider, service) pair. Settings wrote Azure and GCP rows under per-product ids (vm, sql, cosmosdb, redis, memorystore) that no recommendation carries, so those defaults and overrides never applied. The service cards, the override service list and the display names now use the current ids (card DOM ids are unchanged). Service configs stored under an old id are shown on their card and re-saved under the current id on the next Save; a row already stored under the current id wins. Overrides stored under an old id stay listed, with a note naming the id to re-create them under. A new test checks every Azure/GCP id the cards save and the override list offers against mapServiceSlug in internal/purchase/execution.go, with Cosmos DB's nosql as the one documented exception. Refs #613 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/settings-accounts.test.ts | 10 +- .../settings-gcp-storage-removed.test.ts | 4 +- .../__tests__/settings-service-ids.test.ts | 181 ++++++++++++++++++ .../settings-service-payment.test.ts | 14 +- frontend/src/__tests__/settings.test.ts | 2 +- frontend/src/lib/settings-service-ids.ts | 53 +++++ frontend/src/settings.ts | 75 +++++--- frontend/src/styles/settings.css | 6 + 8 files changed, 304 insertions(+), 41 deletions(-) create mode 100644 frontend/src/__tests__/settings-service-ids.test.ts create mode 100644 frontend/src/lib/settings-service-ids.ts diff --git a/frontend/src/__tests__/settings-accounts.test.ts b/frontend/src/__tests__/settings-accounts.test.ts index bd8db15fd..479e72407 100644 --- a/frontend/src/__tests__/settings-accounts.test.ts +++ b/frontend/src/__tests__/settings-accounts.test.ts @@ -688,7 +688,7 @@ describe('Overrides panel — AWS payment selector', () => { // Issue #109: Azure override rows now get inline payment editing. // Azure uses upfront/monthly (not the AWS no-upfront/partial/all-upfront set). (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ - { id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'vm', term: 1, payment: 'upfront' }, + { id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'compute', term: 1, payment: 'upfront' }, ]); const panel = await openOverridesPanel('acc-1'); @@ -983,7 +983,7 @@ describe('Overrides panel: inline Term/Coverage/Enabled (issue #110)', () => { // Issue #109: Azure override rows now have inline editing on all fields, // same as AWS rows after issue #110. (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ - { id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'vm', term: 1, coverage: 80 }, + { id: 'o1', account_id: 'acc-1', provider: 'azure', service: 'compute', term: 1, coverage: 80 }, ]); const panel = await openOverridesPanel('acc-1'); @@ -1212,9 +1212,9 @@ describe('Create-override modal', () => { // Service dropdown lists Azure services, not AWS services. const svcSel = document.getElementById('override-service') as HTMLSelectElement; const svcValues = Array.from(svcSel.options).map(o => o.value); - expect(svcValues).toContain('vm'); - expect(svcValues).toContain('sql'); - expect(svcValues).toContain('cosmosdb'); + expect(svcValues).toContain('compute'); + expect(svcValues).toContain('relational-db'); + expect(svcValues).toContain('nosql'); expect(svcValues).not.toContain('ec2'); expect(svcValues).not.toContain('rds'); }); diff --git a/frontend/src/__tests__/settings-gcp-storage-removed.test.ts b/frontend/src/__tests__/settings-gcp-storage-removed.test.ts index 848226ec1..1f455286b 100644 --- a/frontend/src/__tests__/settings-gcp-storage-removed.test.ts +++ b/frontend/src/__tests__/settings-gcp-storage-removed.test.ts @@ -50,7 +50,7 @@ describe('GCP storage removal (issue #543)', () => { await new Promise(r => setTimeout(r, 0)); }); - it('offers compute, sql and memorystore, not storage, in the override Add list', async () => { + it('offers compute, relational-db and cache, not storage, in the override Add list', async () => { (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); panel.innerHTML = ''; await loadOverridesPanel('acct1', panel, 'gcp' as never); @@ -59,7 +59,7 @@ describe('GCP storage removal (issue #543)', () => { add.click(); await new Promise(r => setTimeout(r, 0)); const options = Array.from((document.getElementById('override-service') as HTMLSelectElement).options).map(o => o.value); - expect(options).toEqual(['compute', 'sql', 'memorystore']); + expect(options).toEqual(['compute', 'relational-db', 'cache']); }); it('still renders a stored gcp/storage override with a Delete button', () => { diff --git a/frontend/src/__tests__/settings-service-ids.test.ts b/frontend/src/__tests__/settings-service-ids.test.ts new file mode 100644 index 000000000..317492498 --- /dev/null +++ b/frontend/src/__tests__/settings-service-ids.test.ts @@ -0,0 +1,181 @@ +/** + * Settings service ids for Azure and GCP (issue #613). + * + * Recommendations carry the provider's common.ServiceType slug and the + * scheduler / purchase flow match service configs and per-account overrides + * on that exact id, so Settings must write the same ids. The DOM is the real + * frontend/src/index.html and the backend slug list is read from + * internal/purchase/execution.go (mapServiceSlug), so a drift fails here. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { loadGlobalSettings, saveGlobalSettings, loadOverridesPanel } from '../settings'; +import { SETTINGS_SERVICE_IDS, LEGACY_SETTINGS_SERVICES, canonicalizeServiceConfigs, legacySettingsServiceTarget } from '../lib/settings-service-ids'; + +jest.mock('../api', () => ({ + getConfig: jest.fn(), + updateConfig: jest.fn(), + updateServiceConfig: jest.fn(), + getLadderConfigs: jest.fn().mockResolvedValue([]), + listAccountServiceOverrides: jest.fn(), + saveAccountServiceOverride: jest.fn(), + deleteAccountServiceOverride: jest.fn(), +})); +jest.mock('../federation', () => ({ initFederationPanel: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.resolve(true)) })); +jest.mock('../toast', () => ({ showToast: jest.fn(() => ({ dismiss: jest.fn() })) })); + +import * as api from '../api'; + +// Cosmos DB recommendations carry common.ServiceNoSQL ("nosql"), which +// mapServiceSlug does not list yet (no Cosmos purchase path). Settings must +// still use that id so its config and overrides match those recommendations. +const NOT_IN_PURCHASE_SLUGS = ['azure/nosql']; + +function backendServiceSlugs(): string[] { + const src = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'internal', 'purchase', 'execution.go'), 'utf-8'); + const block = /func mapServiceSlug\(service string\)[\s\S]*?slugs := map\[string\]common\.ServiceType\{([\s\S]*?)\n\t\}/.exec(src); + if (!block) throw new Error('mapServiceSlug slug map not found in execution.go'); + const slugs = [...block[1]!.matchAll(/"([\w-]+)":/g)].map(m => m[1]!); + if (!slugs.includes('compute')) throw new Error(`unexpected mapServiceSlug parse: ${JSON.stringify(slugs)}`); + return slugs; +} + +function mountRealIndexHtml(): void { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8'); + document.body.innerHTML = new DOMParser().parseFromString(html, 'text/html').body.innerHTML; +} + +const flush = () => new Promise(r => setTimeout(r, 0)); + +function mockConfig(services: unknown[]): void { + (api.getConfig as jest.Mock).mockResolvedValue({ + global: { enabled_providers: ['aws', 'azure', 'gcp'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80, notification_days_before: 3 }, + services, + }); +} + +function servicePuts(provider?: string): Array<{ provider: string; service: string; cfg: Record }> { + return (api.updateServiceConfig as jest.Mock).mock.calls + .map(([p, s, cfg]) => ({ provider: p, service: s, cfg })) + .filter(c => provider === undefined || c.provider === provider); +} + +async function overrideOptions(provider: 'azure' | 'gcp'): Promise { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([]); + const panel = document.createElement('div'); + document.body.appendChild(panel); + await loadOverridesPanel('acct1', panel, provider); + await flush(); + return Array.from((document.getElementById('override-service') as HTMLSelectElement).options).map(o => o.value); +} + +describe('canonicalizeServiceConfigs (issue #613)', () => { + it('maps every legacy id onto a current Settings id of the same provider', () => { + for (const [key, target] of Object.entries(LEGACY_SETTINGS_SERVICES)) { + const provider = key.split('/')[0] as 'azure' | 'gcp'; + expect(SETTINGS_SERVICE_IDS[provider]).toContain(target); + } + }); + + it('re-keys a legacy row, keeps a current row over its legacy twin and passes others through', () => { + const rows = [ + { provider: 'azure', service: 'vm', term: 1 }, + { provider: 'gcp', service: 'memorystore', term: 1 }, + { provider: 'gcp', service: 'cache', term: 3 }, + { provider: 'aws', service: 'ec2', term: 3 }, + { provider: 'gcp', service: 'storage', term: 1 }, + ]; + expect(canonicalizeServiceConfigs(rows)).toEqual([ + { provider: 'azure', service: 'compute', term: 1 }, + { provider: 'gcp', service: 'cache', term: 3 }, + { provider: 'aws', service: 'ec2', term: 3 }, + { provider: 'gcp', service: 'storage', term: 1 }, + ]); + expect(rows[0]!.service).toBe('vm'); + }); + + it.each([['aws', 'vm'], ['azure', 'compute'], ['gcp', 'cloudsql'], ['azure', 'toString'], ['', '']])( + 'does not treat %s/%s as a legacy Settings id', (provider, service) => { + expect(legacySettingsServiceTarget(provider, service)).toBeNull(); + }); +}); + +describe('Settings Azure/GCP service ids (issue #613)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mountRealIndexHtml(); + mockConfig([]); + (api.updateConfig as jest.Mock).mockResolvedValue(undefined); + (api.updateServiceConfig as jest.Mock).mockResolvedValue(undefined); + }); + + it('every Azure/GCP id the cards save and the override list offers is a backend service slug', async () => { + await loadGlobalSettings(); + await saveGlobalSettings(new Event('submit')); + const slugs = backendServiceSlugs(); + const offered: string[] = []; + for (const provider of ['azure', 'gcp'] as const) { + const saved = servicePuts(provider).map(c => c.service); + expect(saved).toEqual(SETTINGS_SERVICE_IDS[provider]); + expect(await overrideOptions(provider)).toEqual(SETTINGS_SERVICE_IDS[provider]); + offered.push(...saved.map(s => `${provider}/${s}`)); + } + expect(offered.filter(id => !slugs.includes(id.split('/')[1]!))).toEqual(NOT_IN_PURCHASE_SLUGS); + }); + + it('shows rows stored under the old ids on their cards and saves them under the current ids', async () => { + mockConfig([ + { provider: 'azure', service: 'vm', term: 1, payment: 'monthly', enabled: true, coverage: 80, ramp_schedule: 'r1', include_regions: ['eastus'] }, + { provider: 'azure', service: 'cosmosdb', term: 1, payment: 'monthly', enabled: true, coverage: 80 }, + { provider: 'gcp', service: 'memorystore', term: 1, payment: 'monthly', enabled: true, coverage: 80 }, + ]); + await loadGlobalSettings(); + expect((document.getElementById('azure-vm-term') as HTMLSelectElement).value).toBe('1'); + expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe('monthly'); + expect((document.getElementById('azure-cosmosdb-term') as HTMLSelectElement).value).toBe('1'); + expect((document.getElementById('gcp-memorystore-term') as HTMLSelectElement).value).toBe('1'); + expect((document.getElementById('azure-sql-term') as HTMLSelectElement).value).toBe('3'); + + await saveGlobalSettings(new Event('submit')); + const puts = servicePuts(); + const legacy = new Set(Object.keys(LEGACY_SETTINGS_SERVICES)); + expect(puts.filter(c => legacy.has(`${c.provider}/${c.service}`))).toEqual([]); + const compute = puts.find(c => c.provider === 'azure' && c.service === 'compute')!.cfg; + expect(compute).toEqual(expect.objectContaining({ term: 1, payment: 'monthly', ramp_schedule: 'r1', include_regions: ['eastus'] })); + expect(puts.find(c => c.provider === 'azure' && c.service === 'nosql')!.cfg['term']).toBe(1); + expect(puts.find(c => c.provider === 'gcp' && c.service === 'cache')!.cfg['term']).toBe(1); + }); + + it('prefers a row already stored under the current id over its legacy twin', async () => { + mockConfig([ + { provider: 'azure', service: 'vm', term: 1, payment: 'monthly', enabled: true, coverage: 80 }, + { provider: 'azure', service: 'compute', term: 3, payment: 'upfront', enabled: true, coverage: 80 }, + ]); + await loadGlobalSettings(); + expect((document.getElementById('azure-vm-term') as HTMLSelectElement).value).toBe('3'); + expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe('upfront'); + }); + + it('flags an override stored under an old id and still offers the current id', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'o1', account_id: 'acct1', provider: 'azure', service: 'vm', term: 1, enabled: true }, + { id: 'o2', account_id: 'acct1', provider: 'azure', service: 'relational-db', term: 1, enabled: true }, + ]); + const panel = document.createElement('div'); + document.body.appendChild(panel); + await loadOverridesPanel('acct1', panel, 'azure'); + await flush(); + const rows = Array.from(panel.querySelectorAll('tbody tr')); + const vmRow = rows.find(r => r.querySelector('td')!.textContent!.startsWith('azure/vm'))!; + const dbRow = rows.find(r => r.querySelector('td')!.textContent!.startsWith('azure/relational-db'))!; + expect(vmRow.querySelector('.override-legacy-hint')!.textContent).toContain('azure/compute'); + expect(dbRow.querySelector('.override-legacy-hint')).toBeNull(); + + (panel.querySelector('button') as HTMLButtonElement).click(); + await flush(); + const options = Array.from((document.getElementById('override-service') as HTMLSelectElement).options).map(o => o.value); + expect(options).toEqual(['compute', 'nosql', 'cache', 'search']); + }); +}); diff --git a/frontend/src/__tests__/settings-service-payment.test.ts b/frontend/src/__tests__/settings-service-payment.test.ts index 1f2b8fe37..563cfccb7 100644 --- a/frontend/src/__tests__/settings-service-payment.test.ts +++ b/frontend/src/__tests__/settings-service-payment.test.ts @@ -98,8 +98,8 @@ describe('Settings Save payment tokens (issue #545)', () => { await saveGlobalSettings(new Event('submit')); const calls = serviceCalls(); - expect(calls.find(c => c.provider === 'azure' && c.service === 'vm')!.payment).toBe('monthly'); - expect(calls.find(c => c.provider === 'azure' && c.service === 'sql')!.payment).toBe('upfront'); + expect(calls.find(c => c.provider === 'azure' && c.service === 'compute')!.payment).toBe('monthly'); + expect(calls.find(c => c.provider === 'azure' && c.service === 'relational-db')!.payment).toBe('upfront'); for (const c of calls.filter(x => x.provider === 'gcp')) expect(c.payment).toBe('monthly'); }); @@ -107,7 +107,7 @@ describe('Settings Save payment tokens (issue #545)', () => { expect(document.getElementById('gcp-storage-term')).toBeNull(); await save(); expect(serviceCalls().filter(c => c.provider === 'gcp').map(c => c.service)) - .toEqual(['compute', 'sql', 'memorystore']); + .toEqual(['compute', 'relational-db', 'cache']); }); it('ignores a stored gcp/storage row on load and keeps the other GCP cards', async () => { @@ -150,7 +150,7 @@ describe('Settings Save payment tokens (issue #545)', () => { it('one failed service PUT reports that service, still saves the rest and keeps only its fields dirty', async () => { const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); (api.updateServiceConfig as jest.Mock).mockImplementation(async (provider: string, service: string) => { - if (provider === 'azure' && service === 'vm') throw new Error('invalid payment option'); + if (provider === 'azure' && service === 'compute') throw new Error('invalid payment option'); }); await loadGlobalSettings(); const azurePayment = document.getElementById('azure-vm-payment') as HTMLSelectElement; @@ -163,7 +163,7 @@ describe('Settings Save payment tokens (issue #545)', () => { expect(api.updateServiceConfig).toHaveBeenCalledTimes(17); expect(mockShowToast).toHaveBeenCalledTimes(1); expect(mockShowToast).toHaveBeenCalledWith({ - message: 'Settings saved, but 1 service failed: azure/vm: invalid payment option', + message: 'Settings saved, but 1 service failed: azure/compute: invalid payment option', kind: 'error', }); expect(azurePayment.classList.contains('dirty')).toBe(true); @@ -184,7 +184,7 @@ describe('Settings Save payment tokens (issue #545)', () => { await save(); expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe(expected); - expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'vm')!.payment).toBe(expected); + expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'compute')!.payment).toBe(expected); }); it('never falls back to upfront when a payment select has no matching option at Save', async () => { @@ -193,7 +193,7 @@ describe('Settings Save payment tokens (issue #545)', () => { expect((document.getElementById('azure-vm-payment') as HTMLSelectElement).value).toBe(''); await saveGlobalSettings(new Event('submit')); - expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'vm')!.payment).toBe('monthly'); + expect(serviceCalls().find(c => c.provider === 'azure' && c.service === 'compute')!.payment).toBe('monthly'); }); it('counts only AWS services when asking to propagate the default payment', async () => { diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts index 0736b4e97..214d34e1f 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -1409,7 +1409,7 @@ describe('Settings Module', () => { await saveGlobalSettings({ preventDefault: jest.fn() } as unknown as Event); const azureVmCall = (api.updateServiceConfig as jest.Mock).mock.calls.find( - ([provider, service]) => provider === 'azure' && service === 'vm', + ([provider, service]) => provider === 'azure' && service === 'compute', ); expect(azureVmCall).toBeDefined(); const cfg = azureVmCall![2]; diff --git a/frontend/src/lib/settings-service-ids.ts b/frontend/src/lib/settings-service-ids.ts new file mode 100644 index 000000000..a7bc09c1d --- /dev/null +++ b/frontend/src/lib/settings-service-ids.ts @@ -0,0 +1,53 @@ +// Service ids for the Settings page's Azure/GCP per-service defaults and +// per-account overrides (#613). +// +// Recommendations carry the provider's common.ServiceType slug (compute, +// relational-db, nosql, cache, search), and both the scheduler +// (config.ResolveAccountConfigsForRecs) and the purchase modal look up +// service_configs / account_service_overrides rows by that exact +// (provider, service) pair. Settings used to write Azure/GCP rows under +// per-product ids (vm, sql, cosmosdb, redis, memorystore) that no +// recommendation carries, so those rows never applied. + +/** Service ids the Settings page writes for Azure and GCP, per provider. */ +export const SETTINGS_SERVICE_IDS: Readonly> = { + azure: ['compute', 'relational-db', 'nosql', 'cache', 'search'], + gcp: ['compute', 'relational-db', 'cache'], +}; + +/** Old Settings ids (`provider/service`) mapped to the id recommendations carry. */ +export const LEGACY_SETTINGS_SERVICES: Readonly> = { + 'azure/vm': 'compute', + 'azure/sql': 'relational-db', + 'azure/cosmosdb': 'nosql', + 'azure/redis': 'cache', + 'gcp/sql': 'relational-db', + 'gcp/memorystore': 'cache', +}; + +/** The current id for a row stored under an old Settings id, or null when the id is not a legacy one. */ +export function legacySettingsServiceTarget(provider: string, service: string): string | null { + const key = `${provider}/${service}`; + return Object.prototype.hasOwnProperty.call(LEGACY_SETTINGS_SERVICES, key) ? LEGACY_SETTINGS_SERVICES[key]! : null; +} + +/** + * Re-keys rows stored under an old Settings id onto the current id so the + * matching card shows the stored values and the next Save writes them under + * the id the backend matches. A row already stored under the current id wins + * over its legacy twin, which is dropped. Other rows pass through unchanged. + */ +export function canonicalizeServiceConfigs(rows: readonly T[]): T[] { + const present = new Set(rows.map(r => `${r.provider}/${r.service}`)); + const out: T[] = []; + for (const row of rows) { + const target = legacySettingsServiceTarget(row.provider, row.service); + if (target === null) { + out.push(row); + } else if (!present.has(`${row.provider}/${target}`)) { + present.add(`${row.provider}/${target}`); + out.push({ ...row, service: target }); + } + } + return out; +} diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index efbed45af..6e981bb28 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -20,6 +20,7 @@ import { isServiceFilterError, } from './serviceFilters'; import { initLadderingSettings } from './ladder'; +import { canonicalizeServiceConfigs, legacySettingsServiceTarget } from './lib/settings-service-ids'; /** * Issue #196 — after any mutation to an `account_service_overrides` row @@ -132,14 +133,16 @@ const SERVICE_FIELDS = [ { provider: 'aws', service: 'savings-plans-ec2instance', termId: 'aws-savings-plans-ec2instance-term', paymentId: 'aws-savings-plans-ec2instance-payment', coverageId: 'aws-savings-plans-ec2instance-coverage', enabledId: 'aws-savings-plans-ec2instance-enabled' }, { provider: 'aws', service: 'savings-plans-sagemaker', termId: 'aws-savings-plans-sagemaker-term', paymentId: 'aws-savings-plans-sagemaker-payment', coverageId: 'aws-savings-plans-sagemaker-coverage', enabledId: 'aws-savings-plans-sagemaker-enabled' }, { provider: 'aws', service: 'savings-plans-database', termId: 'aws-savings-plans-database-term', paymentId: 'aws-savings-plans-database-payment', coverageId: 'aws-savings-plans-database-coverage', enabledId: 'aws-savings-plans-database-enabled' }, - { provider: 'azure', service: 'vm', termId: 'azure-vm-term', paymentId: 'azure-vm-payment' }, - { provider: 'azure', service: 'sql', termId: 'azure-sql-term', paymentId: 'azure-sql-payment' }, - { provider: 'azure', service: 'cosmosdb', termId: 'azure-cosmosdb-term', paymentId: 'azure-cosmosdb-payment' }, - { provider: 'azure', service: 'redis', termId: 'azure-redis-term', paymentId: 'azure-redis-payment' }, - { provider: 'azure', service: 'search', termId: 'azure-search-term', paymentId: 'azure-search-payment' }, - { provider: 'gcp', service: 'compute', termId: 'gcp-compute-term', paymentId: null }, - { provider: 'gcp', service: 'sql', termId: 'gcp-sql-term', paymentId: null }, - { provider: 'gcp', service: 'memorystore',termId: 'gcp-memorystore-term', paymentId: null }, + // Issue #613: Azure/GCP rows use the service ids recommendations carry + // (lib/settings-service-ids.ts); the card DOM ids keep their old names. + { provider: 'azure', service: 'compute', termId: 'azure-vm-term', paymentId: 'azure-vm-payment' }, + { provider: 'azure', service: 'relational-db', termId: 'azure-sql-term', paymentId: 'azure-sql-payment' }, + { provider: 'azure', service: 'nosql', termId: 'azure-cosmosdb-term', paymentId: 'azure-cosmosdb-payment' }, + { provider: 'azure', service: 'cache', termId: 'azure-redis-term', paymentId: 'azure-redis-payment' }, + { provider: 'azure', service: 'search', termId: 'azure-search-term', paymentId: 'azure-search-payment' }, + { provider: 'gcp', service: 'compute', termId: 'gcp-compute-term', paymentId: null }, + { provider: 'gcp', service: 'relational-db', termId: 'gcp-sql-term', paymentId: null }, + { provider: 'gcp', service: 'cache', termId: 'gcp-memorystore-term', paymentId: null }, ] as const; // Fields that are persisted to the backend via saveGlobalSettings. @@ -680,18 +683,20 @@ const AWS_OVERRIDE_SERVICES: ReadonlyArray<{ value: string; label: string }> = [ // Human-readable labels for Azure and GCP services used in the override // service dropdown. AWS labels are carried by AWS_OVERRIDE_SERVICES above. +// Values are the service ids recommendations carry (issue #613), the same +// ones SERVICE_FIELDS writes. const AZURE_OVERRIDE_SERVICES: ReadonlyArray<{ value: string; label: string }> = [ - { value: 'vm', label: 'Virtual Machines' }, - { value: 'sql', label: 'SQL Database' }, - { value: 'cosmosdb', label: 'Cosmos DB' }, - { value: 'redis', label: 'Cache for Redis' }, - { value: 'search', label: 'AI Search' }, + { value: 'compute', label: 'Virtual Machines' }, + { value: 'relational-db', label: 'SQL Database' }, + { value: 'nosql', label: 'Cosmos DB' }, + { value: 'cache', label: 'Cache for Redis' }, + { value: 'search', label: 'AI Search' }, ]; const GCP_OVERRIDE_SERVICES: ReadonlyArray<{ value: string; label: string }> = [ - { value: 'compute', label: 'Compute Engine' }, - { value: 'sql', label: 'Cloud SQL' }, - { value: 'memorystore', label: 'Memorystore' }, + { value: 'compute', label: 'Compute Engine' }, + { value: 'relational-db', label: 'Cloud SQL' }, + { value: 'cache', label: 'Memorystore' }, ]; /** Return the service list for the given provider's override modal. */ @@ -1173,6 +1178,17 @@ function closeAccountOverridesModal(): void { closeOverrideModal(); } +// Issue #613: overrides saved under an old Azure/GCP service id never match a +// recommendation. Say so on the row and name the id to re-create it under. +function appendLegacyOverrideHint(cell: HTMLElement, o: api.AccountServiceOverride): void { + const target = legacySettingsServiceTarget(o.provider, o.service); + if (target === null) return; + const hint = document.createElement('div'); + hint.className = 'help-text override-legacy-hint'; + hint.textContent = `Old service id: never applied. Re-create it as ${o.provider}/${target}, then delete this row.`; + cell.appendChild(hint); +} + export async function loadOverridesPanel(accountId: string, panel: HTMLElement, provider: AccountProvider): Promise { panel.textContent = 'Loading\u2026'; try { @@ -1240,6 +1256,7 @@ export async function loadOverridesPanel(accountId: string, panel: HTMLElement, const serviceTd = tr.insertCell(); serviceTd.textContent = `${o.provider}/${o.service}`; + appendLegacyOverrideHint(serviceTd, o); // Term cell: editable