diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts
index 9095a769..c6c5c5e6 100644
--- a/frontend/src/__tests__/settings.test.ts
+++ b/frontend/src/__tests__/settings.test.ts
@@ -82,12 +82,16 @@ describe('Settings Module', () => {
+
+
+
@@ -108,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','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'].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';
});
@@ -506,7 +510,53 @@ describe('Settings Module', () => {
expect(api.updateConfig).toHaveBeenCalled();
});
- test('calls updateServiceConfig once per service field (15 calls)', async () => {
+ // 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');
+ 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('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');
+ // 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 () => {
+ (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('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();
@@ -514,8 +564,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);
+ // 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(16);
});
}); // end saveGlobalSettings
@@ -835,6 +887,21 @@ 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('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..d4fdbb08 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -485,10 +485,16 @@
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, and Database (RDS) plans share these defaults. SageMaker is its own SP type — see card below.
+
+
SageMaker Savings Plans
+
SageMaker Savings Plans are a distinct SP product (separate from Compute SP). These defaults narrow SageMaker recommendations independently of the umbrella Savings Plans card.
+
+
+
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts
index aaf49bf8..b0e81139 100644
--- a/frontend/src/settings.ts
+++ b/frontend/src/settings.ts
@@ -32,6 +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 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: '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' },