diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts
index 671fe1afd..bdf128775 100644
--- a/frontend/src/__tests__/riexchange.test.ts
+++ b/frontend/src/__tests__/riexchange.test.ts
@@ -1594,12 +1594,40 @@ describe('excluded RIs notice', () => {
await loadReshapeRecommendations();
const notice = document.getElementById('ri-exchange-recommendations-excluded');
- expect(notice?.textContent).toContain('2 convertible RIs were left out because AWS returned no term length');
+ expect(notice?.textContent).toContain('2 convertible RIs were left out because no term');
expect(notice?.textContent).toContain('ri-0');
expect(notice?.textContent).toContain('
');
expect(notice?.querySelector('img')).toBeNull();
});
+ it('shows each excluded RI\'s server reason, grouped by reason', () => {
+ renderExcludedRIsNotice([
+ { ri_id: 'ri-dur', reason: 'non-positive duration' },
+ { ri_id: 'ri-price', reason: 'no pricing available' },
+ ]);
+ const text = document.getElementById('ri-exchange-recommendations-excluded')?.textContent ?? '';
+ expect(text).toContain('1 convertible RI was left out because non-positive duration: ri-dur');
+ expect(text).toContain('1 convertible RI was left out because no pricing available: ri-price');
+ expect(text).not.toContain('no term length');
+ });
+
+ it('falls back to the term-length text when reason is empty or missing', () => {
+ renderExcludedRIsNotice([
+ { ri_id: 'ri-a', reason: '' },
+ { ri_id: 'ri-b' } as unknown as { ri_id: string; reason: string },
+ ]);
+ expect(document.getElementById('ri-exchange-recommendations-excluded')?.textContent).toContain(
+ '2 convertible RIs were left out because AWS returned no term length: ri-a, ri-b',
+ );
+ });
+
+ it('renders a reason containing HTML special characters as text', () => {
+ renderExcludedRIsNotice([{ ri_id: 'ri-x', reason: 'bad & "quoted"' }]);
+ const notice = document.getElementById('ri-exchange-recommendations-excluded');
+ expect(notice?.textContent).toContain('bad & "quoted"');
+ expect(notice?.querySelector('b')).toBeNull();
+ });
+
it('shows no notice when excluded_ris is empty or absent, and clears a stale one', () => {
renderExcludedRIsNotice([{ ri_id: 'ri-0', reason: 'x' }]);
expect(document.getElementById('ri-exchange-recommendations-excluded')?.textContent).toContain('1 convertible RI was left out');
diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts
index 2d973389d..d9340edb4 100644
--- a/frontend/src/riexchange.ts
+++ b/frontend/src/riexchange.ts
@@ -588,9 +588,18 @@ export function renderExcludedRIsNotice(excluded: ExcludedRI[] | undefined): voi
notice.className = '';
return;
}
- const n = excluded.length;
+ const byReason = new Map();
+ for (const e of excluded) {
+ const reason = e.reason || 'AWS returned no term length';
+ const ids = byReason.get(reason);
+ if (ids) ids.push(e.ri_id);
+ else byReason.set(reason, [e.ri_id]);
+ }
notice.className = 'freshness-banner warning';
- notice.textContent = `${n} convertible RI${n === 1 ? ' was' : 's were'} left out because AWS returned no term length: ${excluded.map((e) => e.ri_id).join(', ')}`;
+ // textContent keeps server-provided reasons and ids inert.
+ notice.textContent = [...byReason]
+ .map(([reason, ids]) => `${ids.length} convertible RI${ids.length === 1 ? ' was' : 's were'} left out because ${reason}: ${ids.join(', ')}`)
+ .join('; ');
}
/**