diff --git a/frontend/src/__tests__/serviceFilters.test.ts b/frontend/src/__tests__/serviceFilters.test.ts
new file mode 100644
index 000000000..b778e9ce6
--- /dev/null
+++ b/frontend/src/__tests__/serviceFilters.test.ts
@@ -0,0 +1,157 @@
+/**
+ * Per-service recommendation-filter controls (serviceFilters.ts).
+ *
+ * Covers the GUI surface for the CLI filter knobs:
+ * - parseCsvList / joinCsvList round-trip + de-dup + trim + lowercase.
+ * - injectServiceFilterControls is idempotent and finds the card.
+ * - populate fills inputs from a ServiceConfig (or clears when absent).
+ * - read returns parsed values, validates min-count, and errors loudly on
+ * a bad min-count instead of silently coercing.
+ */
+import {
+ parseCsvList,
+ joinCsvList,
+ filterInputId,
+ serviceFilterInputIds,
+ injectServiceFilterControls,
+ populateServiceFilterControls,
+ readServiceFilterControls,
+ isServiceFilterError,
+ type ServiceFilterTarget,
+} from '../serviceFilters';
+import type { ServiceConfig } from '../api/types';
+
+const target: ServiceFilterTarget = { provider: 'aws', service: 'rds', termId: 'aws-rds-term' };
+
+function seedCard(): void {
+ document.body.innerHTML = `
+
+
RDS
+
+ `;
+}
+
+describe('parseCsvList / joinCsvList', () => {
+ it('splits on comma and whitespace, trims, lowercases, de-dups', () => {
+ expect(parseCsvList('MySQL, postgres mysql,, AURORA')).toEqual(['mysql', 'postgres', 'aurora']);
+ });
+ it('returns [] for empty / whitespace-only input', () => {
+ expect(parseCsvList('')).toEqual([]);
+ expect(parseCsvList(' , , ')).toEqual([]);
+ });
+ it('joins back to the comma-separated display form', () => {
+ expect(joinCsvList(['mysql', 'postgres'])).toBe('mysql, postgres');
+ expect(joinCsvList(undefined)).toBe('');
+ });
+});
+
+describe('id helpers', () => {
+ it('builds deterministic input ids', () => {
+ expect(filterInputId('aws', 'rds', 'include-engines')).toBe('aws-rds-include-engines');
+ });
+ it('lists all owned input ids including min-count', () => {
+ const ids = serviceFilterInputIds(target);
+ expect(ids).toContain('aws-rds-include-engines');
+ expect(ids).toContain('aws-rds-exclude-regions');
+ expect(ids).toContain('aws-rds-min-count');
+ expect(ids).toHaveLength(7);
+ });
+});
+
+describe('injectServiceFilterControls', () => {
+ beforeEach(seedCard);
+
+ it('injects the panel and all inputs once', () => {
+ expect(injectServiceFilterControls(target)).toBe(true);
+ for (const id of serviceFilterInputIds(target)) {
+ expect(document.getElementById(id)).not.toBeNull();
+ }
+ // min-count input is a number input with min=0
+ const minEl = document.getElementById('aws-rds-min-count') as HTMLInputElement;
+ expect(minEl.type).toBe('number');
+ expect(minEl.min).toBe('0');
+ });
+
+ it('is idempotent (no duplicate panel on re-run)', () => {
+ expect(injectServiceFilterControls(target)).toBe(true);
+ expect(injectServiceFilterControls(target)).toBe(false);
+ expect(document.querySelectorAll('.service-filter-panel')).toHaveLength(1);
+ });
+
+ it('returns false when the card is missing', () => {
+ document.body.innerHTML = '';
+ expect(injectServiceFilterControls(target)).toBe(false);
+ });
+});
+
+describe('populate + read round-trip', () => {
+ beforeEach(() => {
+ seedCard();
+ injectServiceFilterControls(target);
+ });
+
+ it('populates inputs from a ServiceConfig', () => {
+ const svc: ServiceConfig = {
+ provider: 'aws', service: 'rds', enabled: true, term: 3, payment: 'all-upfront', coverage: 80,
+ include_engines: ['mysql', 'postgres'],
+ exclude_types: ['db.t3.micro'],
+ include_regions: ['us-east-1'],
+ min_count: 3,
+ };
+ populateServiceFilterControls(target, svc);
+ expect((document.getElementById('aws-rds-include-engines') as HTMLInputElement).value).toBe('mysql, postgres');
+ expect((document.getElementById('aws-rds-exclude-types') as HTMLInputElement).value).toBe('db.t3.micro');
+ expect((document.getElementById('aws-rds-include-regions') as HTMLInputElement).value).toBe('us-east-1');
+ expect((document.getElementById('aws-rds-min-count') as HTMLInputElement).value).toBe('3');
+ });
+
+ it('clears inputs when no config (min-count -> 0)', () => {
+ populateServiceFilterControls(target, undefined);
+ expect((document.getElementById('aws-rds-include-engines') as HTMLInputElement).value).toBe('');
+ expect((document.getElementById('aws-rds-min-count') as HTMLInputElement).value).toBe('0');
+ });
+
+ it('reads inputs back into parsed values', () => {
+ (document.getElementById('aws-rds-include-engines') as HTMLInputElement).value = 'MySQL, mysql, postgres';
+ (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = '5';
+ const v = readServiceFilterControls(target);
+ expect(isServiceFilterError(v)).toBe(false);
+ if (!isServiceFilterError(v)) {
+ expect(v.include_engines).toEqual(['mysql', 'postgres']);
+ expect(v.min_count).toBe(5);
+ expect(v.exclude_engines).toEqual([]);
+ }
+ });
+
+ it('treats blank min-count as 0', () => {
+ (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = ' ';
+ const v = readServiceFilterControls(target);
+ expect(isServiceFilterError(v)).toBe(false);
+ if (!isServiceFilterError(v)) expect(v.min_count).toBe(0);
+ });
+
+ it('errors loudly on a fractional min-count', () => {
+ (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = '1.5';
+ const v = readServiceFilterControls(target);
+ expect(isServiceFilterError(v)).toBe(true);
+ if (isServiceFilterError(v)) expect(v.message).toContain('whole number');
+ });
+
+ it('errors loudly on a negative min-count', () => {
+ (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = '-2';
+ const v = readServiceFilterControls(target);
+ expect(isServiceFilterError(v)).toBe(true);
+ });
+});
+
+describe('read with no injected panel', () => {
+ it('returns empty lists and 0 min-count (no filters)', () => {
+ document.body.innerHTML = '';
+ const v = readServiceFilterControls(target);
+ expect(isServiceFilterError(v)).toBe(false);
+ if (!isServiceFilterError(v)) {
+ expect(v.include_engines).toEqual([]);
+ expect(v.min_count).toBe(0);
+ }
+ });
+});
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 089903d58..a1956a9d2 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -303,6 +303,10 @@ export interface ServiceConfig {
exclude_regions?: string[];
include_types?: string[];
exclude_types?: string[];
+ /** Minimum instance/node count a recommendation must carry to be surfaced
+ * (GUI equivalent of the CLI --min-count flag). 0 / undefined disables the
+ * filter. */
+ min_count?: number;
}
/** Response from GET /api/info (unauthenticated). Only safe-to-expose fields. */
diff --git a/frontend/src/serviceFilters.ts b/frontend/src/serviceFilters.ts
new file mode 100644
index 000000000..58124ed3f
--- /dev/null
+++ b/frontend/src/serviceFilters.ts
@@ -0,0 +1,231 @@
+/**
+ * Per-service recommendation-filter controls for the Settings → Service
+ * Defaults cards.
+ *
+ * Exposes the CLI filter knobs (include/exclude engines, instance types,
+ * regions, and the --min-count threshold) in the GUI. The controls are
+ * injected into each service card at runtime (keyed off SERVICE_FIELDS) so a
+ * single code path drives every card instead of ~20 hand-written HTML blocks,
+ * and so the inject / populate / read logic stays unit-testable in isolation.
+ *
+ * The backend already persists these fields on config.ServiceConfig and
+ * applies them server-side (engines/types/regions in
+ * scheduler.filterRecsByResolvedConfigs; min_count likewise). This module is
+ * purely the missing GUI surface.
+ *
+ * All DOM construction goes through createElement + .value assignment (never
+ * innerHTML), so user-entered values cannot inject markup.
+ */
+
+import type { ServiceConfig } from './api/types';
+
+/** Identifies a single service card. Subset of a SERVICE_FIELDS entry. */
+export interface ServiceFilterTarget {
+ provider: string;
+ service: string;
+ /** ID of the card's term