From 8d9da2ddabca7d3d8c5621186be19a4c8aa8f173 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 25 Apr 2026 14:22:19 +0200 Subject: [PATCH 1/2] ux(settings): add SageMaker and Lambda purchasing-defaults cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Purchasing previously offered per-service term/payment cards for EC2, RDS, ElastiCache, OpenSearch, Redshift, and the umbrella Savings Plans, but had no cards for SageMaker or Lambda. Both services are significant cost-optimisation targets covered by Savings Plans (SageMaker SP and Compute SP respectively), so the omission forced users to fall back to the global default term/payment for any SageMaker/Lambda recommendation instead of pinning a per-workload preference. Add `aws/sagemaker` and `aws/lambda` cards mirroring the existing AWS card shape (1y/3y term, NoUpfront / Partial / AllUpfront payment). Both services use AWS_PAYMENTS via commitmentOptions.ts's `_default` fallback — no per-service constraints. The new entries plug into the existing SERVICE_FIELDS array so save / load / dirty-tracking / sticky save bar wiring picks them up automatically. The umbrella Savings Plans hint copy is tweaked to clarify the new per-service cards override it. Tests cover render presence (grep index.html for heading + select IDs) and save round-trip (per-service config endpoint receives the edited term and payment) for each service, plus constraint-suite regression tests confirming the 3yr / no-upfront combination stays selectable (SageMaker / Lambda have no service-level restriction). Closes #22 --- frontend/src/__tests__/settings.test.ts | 113 ++++++++++++++++++++++-- frontend/src/index.html | 14 ++- frontend/src/settings.ts | 6 ++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts index 9095a769..60da4651 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -82,12 +82,17 @@ describe('Settings Module', () => { + + + + + @@ -108,11 +113,11 @@ describe('Settings Module', () => { (document.getElementById('setting-default-payment') as HTMLSelectElement).value = 'all-upfront'; // Mirror the same baseline onto every per-service select so the cascade // diff only reports genuinely-changed rows. - ['aws-ec2-term','aws-rds-term','aws-elasticache-term','aws-opensearch-term','aws-redshift-term','aws-savingsplans-term','azure-vm-term','azure-sql-term','azure-cosmos-term','gcp-compute-term','gcp-sql-term'].forEach(id => { + ['aws-ec2-term','aws-rds-term','aws-elasticache-term','aws-opensearch-term','aws-redshift-term','aws-savingsplans-term','aws-sagemaker-term','aws-lambda-term','azure-vm-term','azure-sql-term','azure-cosmos-term','gcp-compute-term','gcp-sql-term'].forEach(id => { const el = document.getElementById(id) as HTMLSelectElement | null; if (el) el.value = '3'; }); - ['aws-ec2-payment','aws-rds-payment','aws-elasticache-payment','aws-opensearch-payment','aws-redshift-payment','aws-savingsplans-payment'].forEach(id => { + ['aws-ec2-payment','aws-rds-payment','aws-elasticache-payment','aws-opensearch-payment','aws-redshift-payment','aws-savingsplans-payment','aws-sagemaker-payment','aws-lambda-payment'].forEach(id => { const el = document.getElementById(id) as HTMLSelectElement | null; if (el) el.value = 'all-upfront'; }); @@ -506,7 +511,73 @@ describe('Settings Module', () => { expect(api.updateConfig).toHaveBeenCalled(); }); - test('calls updateServiceConfig once per service field (15 calls)', async () => { + // Issue #22: SageMaker and Lambda need their own purchasing-defaults + // cards alongside EC2/RDS/etc. Verify the cards exist in the canonical + // index.html (so the deployed UI actually renders them) and that the + // save flow round-trips edits to the per-service config endpoint. + test('SageMaker card present in index.html with term and payment selects (issue #22)', () => { + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8'); + expect(html).toMatch(/
\s*SageMaker Savings Plans\s*<\/h5>/); + expect(html).toMatch(/id="aws-sagemaker-term"/); + expect(html).toMatch(/id="aws-sagemaker-payment"/); + }); + + test('Lambda card present in index.html with term and payment selects (issue #22)', () => { + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8'); + expect(html).toMatch(/
\s*Lambda Savings Plans\s*<\/h5>/); + expect(html).toMatch(/id="aws-lambda-term"/); + expect(html).toMatch(/id="aws-lambda-payment"/); + }); + + test('saveGlobalSettings sends per-service SageMaker term and payment (issue #22)', async () => { + (api.updateConfig as jest.Mock).mockResolvedValue({}); + (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined); + window.alert = jest.fn(); + + // User pins SageMaker to 1yr / no-upfront while leaving the global + // default at 3yr / all-upfront. + (document.getElementById('aws-sagemaker-term') as HTMLSelectElement).value = '1'; + (document.getElementById('aws-sagemaker-payment') as HTMLSelectElement).value = 'no-upfront'; + + const event = { preventDefault: jest.fn() } as unknown as Event; + await saveGlobalSettings(event); + + const call = (api.updateServiceConfig as jest.Mock).mock.calls.find( + ([provider, service]) => provider === 'aws' && service === 'sagemaker', + ); + expect(call).toBeDefined(); + const cfg = call![2]; + expect(cfg.term).toBe(1); + expect(cfg.payment).toBe('no-upfront'); + }); + + test('saveGlobalSettings sends per-service Lambda term and payment (issue #22)', async () => { + (api.updateConfig as jest.Mock).mockResolvedValue({}); + (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined); + window.alert = jest.fn(); + + // User pins Lambda to 1yr / partial-upfront, distinct from the + // umbrella Savings Plans card and the global default. + (document.getElementById('aws-lambda-term') as HTMLSelectElement).value = '1'; + (document.getElementById('aws-lambda-payment') as HTMLSelectElement).value = 'partial-upfront'; + + const event = { preventDefault: jest.fn() } as unknown as Event; + await saveGlobalSettings(event); + + const call = (api.updateServiceConfig as jest.Mock).mock.calls.find( + ([provider, service]) => provider === 'aws' && service === 'lambda', + ); + expect(call).toBeDefined(); + const cfg = call![2]; + expect(cfg.term).toBe(1); + expect(cfg.payment).toBe('partial-upfront'); + }); + + test('calls updateServiceConfig once per service field (17 calls)', async () => { (api.updateConfig as jest.Mock).mockResolvedValue({}); (api.updateServiceConfig as jest.Mock).mockResolvedValue(undefined); window.alert = jest.fn(); @@ -514,8 +585,10 @@ describe('Settings Module', () => { const event = { preventDefault: jest.fn() } as unknown as Event; await saveGlobalSettings(event); - // 6 AWS + 5 Azure (vm, sql, cosmosdb, redis, search) + 4 GCP. - expect(api.updateServiceConfig).toHaveBeenCalledTimes(15); + // 8 AWS (ec2, rds, elasticache, opensearch, redshift, savingsplans, + // sagemaker, lambda — last two added per issue #22) + 5 Azure + // (vm, sql, cosmosdb, redis, search) + 4 GCP. + expect(api.updateServiceConfig).toHaveBeenCalledTimes(17); }); }); // end saveGlobalSettings @@ -835,6 +908,36 @@ describe('Settings Module', () => { }, ); + test('SageMaker 3yr keeps "no-upfront" visible (issue #22 — no service-level restriction)', async () => { + (api.getConfig as jest.Mock).mockResolvedValue({ + global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'no-upfront', default_coverage: 80 }, + services: [{ provider: 'aws', service: 'sagemaker', term: 3, payment: 'no-upfront' }], + }); + setupSettingsHandlers(); + await loadGlobalSettings(); + + const payment = document.getElementById('aws-sagemaker-payment') as HTMLSelectElement; + expect(optVisible(payment, 'no-upfront')).toBe(true); + expect(optVisible(payment, 'partial-upfront')).toBe(true); + expect(optVisible(payment, 'all-upfront')).toBe(true); + expect(payment.value).toBe('no-upfront'); + }); + + test('Lambda 3yr keeps "no-upfront" visible (issue #22 — covered by Compute SP)', async () => { + (api.getConfig as jest.Mock).mockResolvedValue({ + global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'no-upfront', default_coverage: 80 }, + services: [{ provider: 'aws', service: 'lambda', term: 3, payment: 'no-upfront' }], + }); + setupSettingsHandlers(); + await loadGlobalSettings(); + + const payment = document.getElementById('aws-lambda-payment') as HTMLSelectElement; + expect(optVisible(payment, 'no-upfront')).toBe(true); + expect(optVisible(payment, 'partial-upfront')).toBe(true); + expect(optVisible(payment, 'all-upfront')).toBe(true); + expect(payment.value).toBe('no-upfront'); + }); + test('propagating global "no-upfront" to all services while term=3 clamps restricted services', async () => { (api.getConfig as jest.Mock).mockResolvedValue({ global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80 }, diff --git a/frontend/src/index.html b/frontend/src/index.html index edb424a1..e581f72a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -485,10 +485,22 @@
Redshift Reserved Nodes
Savings Plans
-

Compute (EC2, Fargate, Lambda), EC2 Instance, SageMaker, and Database (RDS) plans share these defaults.

+

Compute (EC2, Fargate, Lambda), EC2 Instance, SageMaker, and Database (RDS) plans share these defaults unless overridden by the per-service cards below.

+
+
SageMaker Savings Plans
+

Defaults for SageMaker Savings Plan recommendations. Falls back to the Savings Plans card above when unset.

+ + +
+
+
Lambda Savings Plans
+

Lambda usage is covered by Compute Savings Plans. These defaults narrow Lambda recommendations independently of the umbrella Savings Plans card.

+ + +
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index aaf49bf8..acabbd41 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -32,6 +32,12 @@ const SERVICE_FIELDS = [ { provider: 'aws', service: 'opensearch', termId: 'aws-opensearch-term', paymentId: 'aws-opensearch-payment' }, { provider: 'aws', service: 'redshift', termId: 'aws-redshift-term', paymentId: 'aws-redshift-payment' }, { provider: 'aws', service: 'savingsplans', termId: 'aws-savingsplans-term', paymentId: 'aws-savingsplans-payment' }, + // Issue #22: SageMaker (its own SP type) and Lambda (covered by Compute SP) + // get dedicated cards so users can pin term/payment per workload instead + // of sharing the umbrella Savings Plans defaults. Both use AWS_PAYMENTS + // (1y/3y, NoUpfront / Partial / AllUpfront) — same shape as EC2/SP. + { provider: 'aws', service: 'sagemaker', termId: 'aws-sagemaker-term', paymentId: 'aws-sagemaker-payment' }, + { provider: 'aws', service: 'lambda', termId: 'aws-lambda-term', paymentId: 'aws-lambda-payment' }, { 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' }, From 68f7f4d83e06910888a92710105f7a25dbc34fe9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 25 Apr 2026 22:53:49 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(settings):=20drop=20invalid=20Lambda=20?= =?UTF-8?q?card=20=E2=80=94=20no=20standalone=20Lambda=20SP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lambda has no dedicated Savings Plan product — Lambda usage is covered exclusively by Compute Savings Plans (alongside EC2 + Fargate), already represented by the umbrella "Savings Plans" card. A separate "Lambda Savings Plans" card invents a non-existent SKU: picking term/payment on it has no purchasable instrument behind it. SageMaker's card stays — SageMaker Savings Plans is a real, distinct SP type (separate product since 2020). - frontend/src/index.html: drop the Lambda service-default-card; rephrase the umbrella SP hint copy so SageMaker is the only call-out (Lambda is implicitly part of "Compute (EC2, Fargate, Lambda)"). - frontend/src/settings.ts: remove the aws/lambda entry from SERVICE_FIELDS, with a comment explaining the omission so a future copy-paste doesn't re-add it. - frontend/src/__tests__/settings.test.ts: drop the three Lambda-specific tests (presence, save round-trip, no-upfront visibility), drop Lambda IDs from the test fixture, and add a negative-guard test asserting no Lambda card or selects ever land in index.html. updateServiceConfig call-count expectation: 17 → 16 (7 AWS + 5 Azure + 4 GCP). --- frontend/src/__tests__/settings.test.ts | 70 ++++++------------------- frontend/src/index.html | 10 +--- frontend/src/settings.ts | 11 ++-- 3 files changed, 25 insertions(+), 66 deletions(-) diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts index 60da4651..c6c5c5e6 100644 --- a/frontend/src/__tests__/settings.test.ts +++ b/frontend/src/__tests__/settings.test.ts @@ -82,9 +82,9 @@ describe('Settings Module', () => { - + - @@ -92,7 +92,6 @@ describe('Settings Module', () => { - @@ -113,11 +112,11 @@ describe('Settings Module', () => { (document.getElementById('setting-default-payment') as HTMLSelectElement).value = 'all-upfront'; // Mirror the same baseline onto every per-service select so the cascade // diff only reports genuinely-changed rows. - ['aws-ec2-term','aws-rds-term','aws-elasticache-term','aws-opensearch-term','aws-redshift-term','aws-savingsplans-term','aws-sagemaker-term','aws-lambda-term','azure-vm-term','azure-sql-term','azure-cosmos-term','gcp-compute-term','gcp-sql-term'].forEach(id => { + ['aws-ec2-term','aws-rds-term','aws-elasticache-term','aws-opensearch-term','aws-redshift-term','aws-savingsplans-term','aws-sagemaker-term','azure-vm-term','azure-sql-term','azure-cosmos-term','gcp-compute-term','gcp-sql-term'].forEach(id => { const el = document.getElementById(id) as HTMLSelectElement | null; if (el) el.value = '3'; }); - ['aws-ec2-payment','aws-rds-payment','aws-elasticache-payment','aws-opensearch-payment','aws-redshift-payment','aws-savingsplans-payment','aws-sagemaker-payment','aws-lambda-payment'].forEach(id => { + ['aws-ec2-payment','aws-rds-payment','aws-elasticache-payment','aws-opensearch-payment','aws-redshift-payment','aws-savingsplans-payment','aws-sagemaker-payment'].forEach(id => { const el = document.getElementById(id) as HTMLSelectElement | null; if (el) el.value = 'all-upfront'; }); @@ -511,10 +510,11 @@ describe('Settings Module', () => { expect(api.updateConfig).toHaveBeenCalled(); }); - // Issue #22: SageMaker and Lambda need their own purchasing-defaults - // cards alongside EC2/RDS/etc. Verify the cards exist in the canonical - // index.html (so the deployed UI actually renders them) and that the + // Issue #22: SageMaker has its own SP type, so it gets a dedicated + // purchasing-defaults card. Verify the card exists in the canonical + // index.html (so the deployed UI actually renders it) and that the // save flow round-trips edits to the per-service config endpoint. + // Lambda is intentionally absent — it has no standalone SP product. test('SageMaker card present in index.html with term and payment selects (issue #22)', () => { const fs = require('fs') as typeof import('fs'); const path = require('path') as typeof import('path'); @@ -524,13 +524,14 @@ describe('Settings Module', () => { expect(html).toMatch(/id="aws-sagemaker-payment"/); }); - test('Lambda card present in index.html with term and payment selects (issue #22)', () => { + test('no Lambda card in index.html — Lambda has no standalone SP, only Compute SP (issue #22)', () => { const fs = require('fs') as typeof import('fs'); const path = require('path') as typeof import('path'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8'); - expect(html).toMatch(/
\s*Lambda Savings Plans\s*<\/h5>/); - expect(html).toMatch(/id="aws-lambda-term"/); - expect(html).toMatch(/id="aws-lambda-payment"/); + // Guard against accidental re-introduction of a Lambda-specific card. + expect(html).not.toMatch(/
\s*Lambda Savings Plans\s*<\/h5>/); + expect(html).not.toMatch(/id="aws-lambda-term"/); + expect(html).not.toMatch(/id="aws-lambda-payment"/); }); test('saveGlobalSettings sends per-service SageMaker term and payment (issue #22)', async () => { @@ -555,29 +556,7 @@ describe('Settings Module', () => { expect(cfg.payment).toBe('no-upfront'); }); - test('saveGlobalSettings sends per-service Lambda term and payment (issue #22)', async () => { - (api.updateConfig as jest.Mock).mockResolvedValue({}); - (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined); - window.alert = jest.fn(); - - // User pins Lambda to 1yr / partial-upfront, distinct from the - // umbrella Savings Plans card and the global default. - (document.getElementById('aws-lambda-term') as HTMLSelectElement).value = '1'; - (document.getElementById('aws-lambda-payment') as HTMLSelectElement).value = 'partial-upfront'; - - const event = { preventDefault: jest.fn() } as unknown as Event; - await saveGlobalSettings(event); - - const call = (api.updateServiceConfig as jest.Mock).mock.calls.find( - ([provider, service]) => provider === 'aws' && service === 'lambda', - ); - expect(call).toBeDefined(); - const cfg = call![2]; - expect(cfg.term).toBe(1); - expect(cfg.payment).toBe('partial-upfront'); - }); - - test('calls updateServiceConfig once per service field (17 calls)', async () => { + test('calls updateServiceConfig once per service field (16 calls)', async () => { (api.updateConfig as jest.Mock).mockResolvedValue({}); (api.updateServiceConfig as jest.Mock).mockResolvedValue(undefined); window.alert = jest.fn(); @@ -585,10 +564,10 @@ describe('Settings Module', () => { const event = { preventDefault: jest.fn() } as unknown as Event; await saveGlobalSettings(event); - // 8 AWS (ec2, rds, elasticache, opensearch, redshift, savingsplans, - // sagemaker, lambda — last two added per issue #22) + 5 Azure + // 7 AWS (ec2, rds, elasticache, opensearch, redshift, savingsplans, + // sagemaker — last one added per issue #22) + 5 Azure // (vm, sql, cosmosdb, redis, search) + 4 GCP. - expect(api.updateServiceConfig).toHaveBeenCalledTimes(17); + expect(api.updateServiceConfig).toHaveBeenCalledTimes(16); }); }); // end saveGlobalSettings @@ -923,21 +902,6 @@ describe('Settings Module', () => { expect(payment.value).toBe('no-upfront'); }); - test('Lambda 3yr keeps "no-upfront" visible (issue #22 — covered by Compute SP)', async () => { - (api.getConfig as jest.Mock).mockResolvedValue({ - global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'no-upfront', default_coverage: 80 }, - services: [{ provider: 'aws', service: 'lambda', term: 3, payment: 'no-upfront' }], - }); - setupSettingsHandlers(); - await loadGlobalSettings(); - - const payment = document.getElementById('aws-lambda-payment') as HTMLSelectElement; - expect(optVisible(payment, 'no-upfront')).toBe(true); - expect(optVisible(payment, 'partial-upfront')).toBe(true); - expect(optVisible(payment, 'all-upfront')).toBe(true); - expect(payment.value).toBe('no-upfront'); - }); - test('propagating global "no-upfront" to all services while term=3 clamps restricted services', async () => { (api.getConfig as jest.Mock).mockResolvedValue({ global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80 }, diff --git a/frontend/src/index.html b/frontend/src/index.html index e581f72a..d4fdbb08 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -485,22 +485,16 @@
Redshift Reserved Nodes
Savings Plans
-

Compute (EC2, Fargate, Lambda), EC2 Instance, SageMaker, and Database (RDS) plans share these defaults unless overridden by the per-service cards below.

+

Compute (EC2, Fargate, Lambda), EC2 Instance, and Database (RDS) plans share these defaults. SageMaker is its own SP type — see card below.

SageMaker Savings Plans
-

Defaults for SageMaker Savings Plan recommendations. Falls back to the Savings Plans card above when unset.

+

SageMaker Savings Plans are a distinct SP product (separate from Compute SP). These defaults narrow SageMaker recommendations independently of the umbrella Savings Plans card.

-
-
Lambda Savings Plans
-

Lambda usage is covered by Compute Savings Plans. These defaults narrow Lambda recommendations independently of the umbrella Savings Plans card.

- - -
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index acabbd41..b0e81139 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -32,12 +32,13 @@ const SERVICE_FIELDS = [ { provider: 'aws', service: 'opensearch', termId: 'aws-opensearch-term', paymentId: 'aws-opensearch-payment' }, { provider: 'aws', service: 'redshift', termId: 'aws-redshift-term', paymentId: 'aws-redshift-payment' }, { provider: 'aws', service: 'savingsplans', termId: 'aws-savingsplans-term', paymentId: 'aws-savingsplans-payment' }, - // Issue #22: SageMaker (its own SP type) and Lambda (covered by Compute SP) - // get dedicated cards so users can pin term/payment per workload instead - // of sharing the umbrella Savings Plans defaults. Both use AWS_PAYMENTS - // (1y/3y, NoUpfront / Partial / AllUpfront) — same shape as EC2/SP. + // Issue #22: SageMaker has its own SP type (SageMaker Savings Plans), + // so it gets a dedicated card and users can pin term/payment per workload + // instead of sharing the umbrella Savings Plans defaults. Lambda is + // intentionally NOT here — Lambda has no standalone SP product; its + // commitments roll up into Compute Savings Plans, already covered by + // the umbrella card above. { provider: 'aws', service: 'sagemaker', termId: 'aws-sagemaker-term', paymentId: 'aws-sagemaker-payment' }, - { provider: 'aws', service: 'lambda', termId: 'aws-lambda-term', paymentId: 'aws-lambda-payment' }, { 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' },