diff --git a/frontend/src/__tests__/settings-accounts.test.ts b/frontend/src/__tests__/settings-accounts.test.ts index ca89b2b9..d744997d 100644 --- a/frontend/src/__tests__/settings-accounts.test.ts +++ b/frontend/src/__tests__/settings-accounts.test.ts @@ -691,7 +691,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'); @@ -986,7 +986,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'); @@ -1215,9 +1215,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 848226ec..1f455286 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 00000000..31749249 --- /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 0b6d22df..607203ab 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); @@ -241,7 +241,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 () => { @@ -250,7 +250,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 46d34f3b..99f238f3 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -1434,7 +1434,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 00000000..a7bc09c1 --- /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 4ef97a90..049926c2 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -21,6 +21,7 @@ import { isServiceFilterError, } from './serviceFilters'; import { initLadderingSettings } from './ladder'; +import { canonicalizeServiceConfigs, legacySettingsServiceTarget } from './lib/settings-service-ids'; import { buildLastUpdatedStamp } from './lib/last-updated'; /** @@ -134,14 +135,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. @@ -699,18 +702,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. */ @@ -1192,6 +1197,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 { @@ -1263,6 +1279,8 @@ export async function loadOverridesPanel(accountId: string, panel: HTMLElement, // the column order users learned (#110) stays put. const stamp = buildLastUpdatedStamp(o.updated_at); if (stamp) serviceTd.append(stamp); + // Old Azure/GCP service id (#613): warn below the stamp. + appendLegacyOverrideHint(serviceTd, o); // Term cell: editable