diff --git a/frontend/src/__tests__/settings.test.ts b/frontend/src/__tests__/settings.test.ts
index c4848ce62..2a5dade9e 100644
--- a/frontend/src/__tests__/settings.test.ts
+++ b/frontend/src/__tests__/settings.test.ts
@@ -95,6 +95,15 @@ describe('Settings Module', () => {
+
+
+
+
+
+
+
+
+
@@ -590,6 +599,9 @@ describe('Settings Module', () => {
for (const planType of ['compute', 'ec2instance', 'sagemaker', 'database']) {
expect(html).toMatch(new RegExp(`id="aws-savings-plans-${planType}-term"`));
expect(html).toMatch(new RegExp(`id="aws-savings-plans-${planType}-payment"`));
+ // Issue #136: coverage and enabled controls must be present on each card.
+ expect(html).toMatch(new RegExp(`id="aws-savings-plans-${planType}-coverage"`));
+ expect(html).toMatch(new RegExp(`id="aws-savings-plans-${planType}-enabled"`));
}
});
@@ -626,6 +638,86 @@ describe('Settings Module', () => {
expect(cfg.payment).toBe('no-upfront');
});
+ test('saveGlobalSettings sends per-card coverage from SP coverage input (issue #136)', async () => {
+ (api.updateConfig as jest.Mock).mockResolvedValue({});
+ (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined);
+ window.alert = jest.fn();
+
+ // Pin compute SP to 60% coverage, leave others at default 80%.
+ (document.getElementById('aws-savings-plans-compute-coverage') as HTMLInputElement).value = '60';
+
+ const event = { preventDefault: jest.fn() } as unknown as Event;
+ await saveGlobalSettings(event);
+
+ const computeCall = (api.updateServiceConfig as jest.Mock).mock.calls.find(
+ ([provider, service]) => provider === 'aws' && service === 'savings-plans-compute',
+ );
+ expect(computeCall).toBeDefined();
+ expect(computeCall![2].coverage).toBe(60);
+
+ // Other SP cards keep their default value.
+ const ec2Call = (api.updateServiceConfig as jest.Mock).mock.calls.find(
+ ([provider, service]) => provider === 'aws' && service === 'savings-plans-ec2instance',
+ );
+ expect(ec2Call).toBeDefined();
+ expect(ec2Call![2].coverage).toBe(80);
+ });
+
+ test('saveGlobalSettings sends per-card enabled=false when SP enabled checkbox unchecked (issue #136)', async () => {
+ (api.updateConfig as jest.Mock).mockResolvedValue({});
+ (api.updateServiceConfig as jest.Mock).mockClear().mockResolvedValue(undefined);
+ window.alert = jest.fn();
+
+ // Disable the database SP card.
+ (document.getElementById('aws-savings-plans-database-enabled') as HTMLInputElement).checked = false;
+
+ const event = { preventDefault: jest.fn() } as unknown as Event;
+ await saveGlobalSettings(event);
+
+ const dbCall = (api.updateServiceConfig as jest.Mock).mock.calls.find(
+ ([provider, service]) => provider === 'aws' && service === 'savings-plans-database',
+ );
+ expect(dbCall).toBeDefined();
+ expect(dbCall![2].enabled).toBe(false);
+
+ // Other SP cards remain enabled.
+ const computeCall = (api.updateServiceConfig as jest.Mock).mock.calls.find(
+ ([provider, service]) => provider === 'aws' && service === 'savings-plans-compute',
+ );
+ expect(computeCall).toBeDefined();
+ expect(computeCall![2].enabled).toBe(true);
+ });
+
+ test('loadGlobalSettings populates SP coverage and enabled from service config (issue #136)', async () => {
+ // Seed a non-default initial DOM state on the fallback card so the test
+ // fails if the fallback assignment is skipped (it would otherwise pass on
+ // the HTML defaults, which happen to equal the asserted values).
+ (document.getElementById('aws-savings-plans-ec2instance-coverage') as HTMLInputElement).value = '12';
+ (document.getElementById('aws-savings-plans-ec2instance-enabled') as HTMLInputElement).checked = false;
+
+ (api.getConfig as jest.Mock).mockResolvedValue({
+ // Non-80 global default so the fallback writes an observably different value.
+ global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'all-upfront', default_coverage: 67 },
+ services: [
+ { provider: 'aws', service: 'savings-plans-compute', term: 1, payment: 'no-upfront', coverage: 65, enabled: false },
+ ],
+ });
+ setupSettingsHandlers();
+ await loadGlobalSettings();
+
+ const coverageEl = document.getElementById('aws-savings-plans-compute-coverage') as HTMLInputElement;
+ const enabledEl = document.getElementById('aws-savings-plans-compute-enabled') as HTMLInputElement;
+ expect(coverageEl.value).toBe('65');
+ expect(enabledEl.checked).toBe(false);
+
+ // Cards without an explicit service row fall back to the global default
+ // (67, not the HTML default 80) and re-enable from the seeded false state.
+ const ec2Coverage = document.getElementById('aws-savings-plans-ec2instance-coverage') as HTMLInputElement;
+ const ec2Enabled = document.getElementById('aws-savings-plans-ec2instance-enabled') as HTMLInputElement;
+ expect(ec2Coverage.value).toBe('67');
+ expect(ec2Enabled.checked).toBe(true);
+ });
+
test('calls updateServiceConfig once per service field (18 calls)', async () => {
(api.updateConfig as jest.Mock).mockResolvedValue({});
(api.updateServiceConfig as jest.Mock).mockResolvedValue(undefined);
diff --git a/frontend/src/index.html b/frontend/src/index.html
index 6ce57e316..ade5fa6fb 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -506,24 +506,32 @@
Compute Savings Plans
EC2, Fargate, Lambda — most flexible
+
+
diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts
index e9ebc1838..1e49948a1 100644
--- a/frontend/src/settings.ts
+++ b/frontend/src/settings.ts
@@ -91,10 +91,12 @@ const SERVICE_FIELDS = [
// (when present) overrides the SageMaker slot from PR #71's sagemaker
// row. Lambda is intentionally NOT a separate card — Lambda has no
// standalone SP product; its commitments roll up into Compute SP.
- { provider: 'aws', service: 'savings-plans-compute', termId: 'aws-savings-plans-compute-term', paymentId: 'aws-savings-plans-compute-payment' },
- { provider: 'aws', service: 'savings-plans-ec2instance', termId: 'aws-savings-plans-ec2instance-term', paymentId: 'aws-savings-plans-ec2instance-payment' },
- { provider: 'aws', service: 'savings-plans-sagemaker', termId: 'aws-savings-plans-sagemaker-term', paymentId: 'aws-savings-plans-sagemaker-payment' },
- { provider: 'aws', service: 'savings-plans-database', termId: 'aws-savings-plans-database-term', paymentId: 'aws-savings-plans-database-payment' },
+ // Issue #136: SP cards carry per-card coverageId and enabledId so users
+ // can set divergent coverage and toggle each plan type independently.
+ { provider: 'aws', service: 'savings-plans-compute', termId: 'aws-savings-plans-compute-term', paymentId: 'aws-savings-plans-compute-payment', coverageId: 'aws-savings-plans-compute-coverage', enabledId: 'aws-savings-plans-compute-enabled' },
+ { 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' },
@@ -120,6 +122,9 @@ const TRACKED_FIELDS = [
// Per-service fields
...SERVICE_FIELDS.map(f => f.termId),
...SERVICE_FIELDS.filter(f => f.paymentId !== null).map(f => f.paymentId as string),
+ // Per-product SP fields (issue #136)
+ ...SERVICE_FIELDS.filter(f => 'coverageId' in f).map(f => (f as { coverageId: string }).coverageId),
+ ...SERVICE_FIELDS.filter(f => 'enabledId' in f).map(f => (f as { enabledId: string }).enabledId),
];
// Snapshot of field values at last save (or initial load).
@@ -2666,14 +2671,33 @@ export async function loadGlobalSettings(): Promise {
updateCollectionScheduleVisibility();
}
- if (data.services) {
- loadedServiceConfigs = data.services;
- for (const svc of data.services) {
- const key = `${svc.provider}-${svc.service}`;
- const termEl = document.getElementById(`${key}-term`) as HTMLSelectElement | null;
- if (termEl) termEl.value = String(svc.term);
- const paymentEl = document.getElementById(`${key}-payment`) as HTMLSelectElement | null;
- if (paymentEl) paymentEl.value = svc.payment;
+ const services = data.services ?? [];
+ loadedServiceConfigs = services;
+ for (const svc of services) {
+ const key = `${svc.provider}-${svc.service}`;
+ const termEl = document.getElementById(`${key}-term`) as HTMLSelectElement | null;
+ if (termEl) termEl.value = String(svc.term);
+ const paymentEl = document.getElementById(`${key}-payment`) as HTMLSelectElement | null;
+ if (paymentEl) paymentEl.value = svc.payment;
+ }
+
+ // Issue #136: populate per-product SP coverage and enabled fields for every
+ // SP card the DOM exposes, not just those with a service row. A card whose
+ // service row is absent from the response must still fall back to
+ // global.default_coverage (and enabled=true); leaving it at the HTML default
+ // (80) would persist an incorrect value on the user's next save. Iterate
+ // SERVICE_FIELDS (the source of truth for card IDs) and overlay any matching
+ // service-config values keyed by `${provider}-${service}`.
+ const svcByKey = new Map(services.map(s => [`${s.provider}-${s.service}`, s] as const));
+ for (const field of SERVICE_FIELDS) {
+ const svc = svcByKey.get(`${field.provider}-${field.service}`);
+ if ('coverageId' in field && field.coverageId) {
+ const el = byId(field.coverageId);
+ if (el) el.value = String(svc?.coverage ?? data.global?.default_coverage ?? 80);
+ }
+ if ('enabledId' in field && field.enabledId) {
+ const el = byId(field.enabledId);
+ if (el) el.checked = svc?.enabled !== false;
}
}
@@ -2903,7 +2927,8 @@ export async function saveGlobalSettings(e: Event): Promise {
try {
await api.updateConfig(settings);
- const serviceSaves = SERVICE_FIELDS.map(({ provider, service, termId, paymentId }) => {
+ const serviceSaves = SERVICE_FIELDS.map((field) => {
+ const { provider, service, termId, paymentId } = field;
const term = parseInt(byId(termId)?.value || '3', 10);
const payment = paymentId
? (byId(paymentId)?.value || 'all-upfront')
@@ -2913,14 +2938,31 @@ export async function saveGlobalSettings(e: Event): Promise {
// include_engines, etc. can be set out-of-band (API, future UI, migration)
// and a full UPSERT that only honoured the four term/payment/enabled/coverage
// fields would silently wipe them every time the user clicked Save.
+
+ // Issue #136: per-product SP cards expose their own coverage and enabled
+ // controls. Read from the DOM when the card has the controls; fall back
+ // to the base row value (or the global default) otherwise so RI and
+ // Azure/GCP cards continue to inherit the global settings.
+ let coverage = base?.coverage ?? settings.default_coverage;
+ let enabled = base?.enabled ?? true;
+ if ('coverageId' in field && field.coverageId) {
+ const rawCov = byId(field.coverageId)?.value ?? '';
+ const parsed = Number(rawCov);
+ if (rawCov !== '' && Number.isFinite(parsed)) coverage = parsed;
+ }
+ if ('enabledId' in field && field.enabledId) {
+ const el = byId(field.enabledId);
+ if (el) enabled = el.checked;
+ }
+
const cfg: api.ServiceConfig = {
...(base ?? {}),
provider,
service,
- enabled: base?.enabled ?? true,
+ enabled,
term,
payment,
- coverage: base?.coverage ?? settings.default_coverage,
+ coverage,
};
return api.updateServiceConfig(provider, service, cfg);
});