Skip to content

feat(ui/opportunities): expose AWS recommendation lookback selector (closes #909) - #911

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
feat/909-opportunities-lookback
Jun 3, 2026
Merged

feat(ui/opportunities): expose AWS recommendation lookback selector (closes #909)#911
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
feat/909-opportunities-lookback

Conversation

@cristim

@cristim cristim commented Jun 1, 2026

Copy link
Copy Markdown
Member

Closes #909.

What

Exposes the recommendation lookback window (7 / 30 / 60 days) directly in the Opportunities view, mirroring the Admin > Settings control. Both read and write the same global recommendations_lookback_days, so changing one is reflected by the other on next load.

Verified provider scope

Grepped the recommendation collectors on origin/feat/multicloud-web-frontend. Only the AWS collector consumes the field: scheduler.fetchAndConvert builds RecommendationParams.LookbackPeriod from RecommendationsLookbackDays. The Azure and GCP collectors call GetAllRecommendations() with no lookback parameter. So the tooltip says:

Applies to AWS recommendations only (Cost Explorer lookback window); Azure and GCP are unaffected.

(Disabled non-admin variant: "Changing the lookback window requires admin (update:config) permission. Ask an administrator to adjust it in Settings.")

Behaviour

  • Selector rendered into a new #recommendations-toolbar, prefilled from /api/config (default 7).
  • Permissions: gated by canAccess('update', 'config') (the same permission the backend enforces). Non-admin sessions see a disabled control with an explanatory tooltip, never an editable control whose only outcome is a 403. Backend stays authoritative.
  • On change: strict-integer + enum validation, then persist the full cached config with only the lookback overridden. The backend PUT preserves only the two recommendation cycle-params when omitted, so a partial body carrying just recommendations_lookback_days would wipe enabled_providers, default_term, etc. Then it triggers a recommendations re-collect for the new window and reloads the list. The selector is disabled while in flight; a persist failure reverts the value and toasts the error (never silently leaves stale data).
  • Extracted the shared refresh + toast + reload block into startRecommendationsRefresh so the stale-on-open path (feat(frontend/recs): auto-refresh on page open if >24h stale; drop freshness indicator + Refresh button #284) and the lookback-change path dedup against a single in-flight collect.

Tests

New frontend/src/__tests__/recommendations-lookback.test.ts: prefill from config, default-to-7, admin enabled + AWS-scope tooltip, non-admin (user/readonly/null) disabled + permission tooltip, change persists full config + triggers refresh + reloads, no-op when unchanged, persist failure reverts + toasts. Full recommendations|settings jest suite green (515 tests). Type-check + production build clean. No backend changes.

Summary by CodeRabbit

  • New Features
    • Added a lookback selector in the Opportunities tab to configure how many days to search for recommendations; changes persist and trigger a refresh.
  • Permissions
    • Selector enabled/disabled and tooltip text vary by user role.
  • Bug Fixes
    • Reverts UI on failed saves and shows error toasts; ignores tampered/out-of-range values.
  • Tests
    • End-to-end tests covering prefill, persistence, role behavior, refresh triggering, no-op saves, failure handling, and client-side validation.

…loses #909)

Surface the recommendation lookback window (7/30/60 days) directly in the
Opportunities view, mirroring the Admin > Settings control. Both read and
write the same global recommendations_lookback_days, so changing one is
reflected by the other on next load.

- Add a #recommendations-toolbar selector rendered by recommendations.ts,
  prefilled from /api/config (default 7) and gated by the update:config
  permission: non-admin sessions see a disabled control with a tooltip
  explaining why, never an editable control whose only outcome is a 403.
- Tooltip wording verified against the collectors: only the AWS collector
  (scheduler.fetchAndConvert -> RecommendationParams.LookbackPeriod) consumes
  the field; Azure/GCP GetAllRecommendations take no lookback parameter. So
  the scope is "Applies to AWS recommendations only (Cost Explorer lookback
  window); Azure and GCP are unaffected."
- On change: strict-integer + enum validation, persist the FULL cached config
  with only the lookback overridden (the backend PUT preserves only the two
  cycle-params when omitted, so a partial body would wipe siblings), then
  trigger a recommendations re-collect and reload the list. The selector is
  disabled while in flight; a persist failure reverts the value and toasts.
- Extract the shared refresh+toast+reload block into startRecommendationsRefresh
  so the stale-on-open path and the lookback-change path dedup against one
  in-flight collect.

Tests: new recommendations-lookback.test.ts (prefill, admin/non-admin gating,
persist+refresh+reload, error revert). Full recommendations|settings suite green.
@cristim cristim added triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/m Days type/feat New capability labels Jun 1, 2026
@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef92a709-9986-40a8-a634-6be861a92477

📥 Commits

Reviewing files that changed from the base of the PR and between cd686c2 and 31bb6a9.

📒 Files selected for processing (2)
  • frontend/src/__tests__/recommendations-lookback.test.ts
  • frontend/src/recommendations.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/recommendations.ts

📝 Walkthrough

Walkthrough

Adds a recommendations lookback selector to the Opportunities toolbar (mount point in HTML), caches full global config, enables admin-only persistence of recommendations_lookback_days via api.updateConfig(), triggers a deduplicated recommendations refresh on change, adds styling, and provides comprehensive Jest tests.

Changes

Opportunities Lookback Selector

Layer / File(s) Summary
Infrastructure & Config Caching
frontend/src/recommendations.ts
GlobalConfig is imported; lookback options and tooltip strings defined; cachedGlobalConfig stores full config; resetCachedGlobalConfig() exported for tests.
Refresh Deduplication Refactor
frontend/src/recommendations.ts
triggerAutoRefreshIfStale() delegates to startRecommendationsRefresh() which tracks and returns the in-flight refresh promise to prevent concurrent recollects.
Config Loading & Toolbar Wiring
frontend/src/index.html, frontend/src/recommendations.ts
loadRecommendations() caches cfgResponse.global into cachedGlobalConfig; renderLookbackToolbar() is called after rendering to populate the new #recommendations-toolbar mount point.
Selector Rendering & Change Handling
frontend/src/recommendations.ts
renderLookbackToolbar() builds a <select> prefilled from cached config, enables editing only for sessions with update:config permission and sets tooltip text accordingly. onLookbackChange() validates input, disables the control while calling api.updateConfig(), updates cachedGlobalConfig on success and triggers a deduplicated refresh; on failure it reverts the UI and shows an error toast. currentLookbackDays() computes the effective lookback.
Toolbar & Selector Styling
frontend/src/styles/components.css
Adds .recommendations-toolbar, .recommendations-toolbar:empty, and .lookback-control CSS rules for layout and typography.
Comprehensive Test Suite
frontend/src/__tests__/recommendations-lookback.test.ts
Adds Jest tests verifying prefill/default fallback, admin vs non-admin UI and tooltip, persistence that preserves other GlobalConfig fields, refresh triggering, no-op on unchanged selection, persistence failure recovery (toast + revert), and client-side rejection of tampered/out-of-range select values.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • LeanerCloud/CUDly#285: Related changes to recommendations auto-refresh deduplication in frontend/src/recommendations.ts.
  • LeanerCloud/CUDly#486: Related loadRecommendations() changes that consume cfgResponse.global for rendering decisions.

Suggested Labels

priority/p2, severity/medium

Poem

🐰 I hopped into the toolbar bright,

Seven, thirty, sixty — choose your sight.
Admins may change, the clouds will redraw,
Viewers can watch, but cannot withdraw.
A toast for errors, a refresh on cue — hippity-hop, it's new!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ui/opportunities): expose AWS recommendation lookback selector (closes #909)' directly and clearly describes the main change: adding a lookback selector UI to the Opportunities view.
Linked Issues check ✅ Passed The PR successfully implements all coding requirements from #909: lookback selector with 7/30/60 options prefilled from config, AWS-scoped tooltip verified via collector review, persistence via api.updateConfig with strict validation, recommendations refresh/reload on change, permission gating by 'update:config', disabled UI for non-admins, comprehensive test coverage of all flows including failure handling and tampered inputs, and no-op optimization when value is unchanged.
Out of Scope Changes check ✅ Passed All code changes are directly scoped to implementing the #909 requirements: test suite for lookback behavior, toolbar HTML mounting point, selector rendering and state management in recommendations.ts, auto-refresh deduplication refactoring for #284/#909, and CSS styling for the toolbar control.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/909-opportunities-lookback

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim

cristim commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/recommendations.ts (1)

316-343: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

A joined refresh can apply the old lookback window.

If stale-on-open already started a recollect with the previous config, Line 668 just joins that in-flight promise because Line 320 returns early. No second recollect is queued after recommendations_lookback_days is persisted, so the selector can show the new value while the refreshed list still reflects the old window.

Also applies to: 667-669

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/recommendations.ts` around lines 316 - 343,
startRecommendationsRefresh currently returns the existing autoRefreshInFlight
immediately, which causes callers to "join" an in-flight recollect and miss a
needed second recollect if recommendations_lookback_days changed during the
in-flight refresh; modify startRecommendationsRefresh/autoRefreshInFlight
handling so that when an existing autoRefreshInFlight is present you do not just
return it directly but instead attach a .then() that compares the lookback value
before the in-flight refresh started to the persisted
recommendations_lookback_days after it finishes (or set a queueFollowUpRefresh
flag when the lookback is changed), and if they differ schedule/trigger a new
refresh by calling startRecommendationsRefresh(onReload) (or a forced-refresh
variant) so the persisted new window is applied; use the existing symbols
startRecommendationsRefresh, autoRefreshInFlight, refreshRecommendationsAPI,
onReload and recommendations_lookback_days to locate and implement this logic.
🧹 Nitpick comments (1)
frontend/src/__tests__/recommendations-lookback.test.ts (1)

1-14: ⚡ Quick win

Docstring claims a client-side rejection test that the suite doesn't contain.

Line 13 lists "a tampered/out-of-range value is rejected client-side" as verified behavior, but no test in this file exercises that path. Since the PR persists with strict integer + enum validation, this is a real coverage gap (a tampered <select> value or an injected out-of-range value should be rejected before api.updateConfig). Either add the test or drop the line from the docstring.

Want me to draft a test that sets an out-of-range value (e.g. 90) on the select, dispatches change, and asserts api.updateConfig/refreshRecsAPI are not called and the selector reverts?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/recommendations-lookback.test.ts` around lines 1 - 14,
Add a unit test to frontend/src/__tests__/recommendations-lookback.test.ts that
simulates a tampered/out-of-range value on the lookback <select> (e.g. set value
"90" on the rendered OpportunitiesLookback selector), dispatches a change event,
and asserts that api.updateConfig and refreshRecsAPI are not called and the
visible selector value reverts to the prior valid value; mock/spyon
api.updateConfig and refreshRecsAPI, render the same component used by the other
tests (e.g. RecommendationsLookback / OpportunitiesLookback selector), perform
the DOM change + fireEvent.change, then assert no backend calls and that the DOM
shows the original valid option.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/__tests__/recommendations-lookback.test.ts`:
- Around line 15-19: Docstring/test mismatch: the test suite imports
loadRecommendations, resetCachedGlobalConfig, and resetAutoRefreshInFlight but
does not include a test that asserts a tampered/out-of-range lookback value is
rejected client-side as the docstring claims. Either update the docstring to
remove the “tampered/out-of-range value is rejected client-side” line, or add a
unit test that simulates setting an invalid lookback (e.g., out-of-enum or
negative value) and asserts the client-side validation prevents it (by checking
the value is not accepted, an error is thrown, or the UI/state remains
unchanged) using the existing helpers (loadRecommendations,
resetCachedGlobalConfig, resetAutoRefreshInFlight) so the new test lives
alongside the enum/option and persist-failure tests.

In `@frontend/src/recommendations.ts`:
- Around line 466-468: The admin UI allows changing the lookback when
cachedGlobalConfig is null, causing a destructive PUT that wipes other global
settings; fix by preventing save/PUT unless a full config is loaded into
cachedGlobalConfig: in loadRecommendations() and the save handler (where the PUT
occurs) check cachedGlobalConfig and if it's null either fetch the full config
via getConfig() and merge the updated lookback into that full config before PUT
or disable the save UI and surface an error until cachedGlobalConfig is
populated; update logic around cachedGlobalConfig assignment/use so the code
never falls back to an empty {} for the body of the PUT.
- Around line 618-625: The current check uses Number(rawValue) which allows
non-decimal forms (e.g. "0x3c", "7e0"); instead validate rawValue as a clean
base-10 integer string first (e.g. regex like /^[+-]?\d+$/) before converting,
then parse to a number (parseInt or Number) and verify against LOOKBACK_OPTIONS;
update the conditional around parsed (and the variables rawValue, parsed,
currentLookbackDays, and the select element 'recs-lookback-days') to reject any
input that fails the decimal-integer regex or is not included in
LOOKBACK_OPTIONS so the DOM-tamper guard matches the comment's intent.

---

Outside diff comments:
In `@frontend/src/recommendations.ts`:
- Around line 316-343: startRecommendationsRefresh currently returns the
existing autoRefreshInFlight immediately, which causes callers to "join" an
in-flight recollect and miss a needed second recollect if
recommendations_lookback_days changed during the in-flight refresh; modify
startRecommendationsRefresh/autoRefreshInFlight handling so that when an
existing autoRefreshInFlight is present you do not just return it directly but
instead attach a .then() that compares the lookback value before the in-flight
refresh started to the persisted recommendations_lookback_days after it finishes
(or set a queueFollowUpRefresh flag when the lookback is changed), and if they
differ schedule/trigger a new refresh by calling
startRecommendationsRefresh(onReload) (or a forced-refresh variant) so the
persisted new window is applied; use the existing symbols
startRecommendationsRefresh, autoRefreshInFlight, refreshRecommendationsAPI,
onReload and recommendations_lookback_days to locate and implement this logic.

---

Nitpick comments:
In `@frontend/src/__tests__/recommendations-lookback.test.ts`:
- Around line 1-14: Add a unit test to
frontend/src/__tests__/recommendations-lookback.test.ts that simulates a
tampered/out-of-range value on the lookback <select> (e.g. set value "90" on the
rendered OpportunitiesLookback selector), dispatches a change event, and asserts
that api.updateConfig and refreshRecsAPI are not called and the visible selector
value reverts to the prior valid value; mock/spyon api.updateConfig and
refreshRecsAPI, render the same component used by the other tests (e.g.
RecommendationsLookback / OpportunitiesLookback selector), perform the DOM
change + fireEvent.change, then assert no backend calls and that the DOM shows
the original valid option.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 84f153e7-aef9-4cde-adee-ce0425a6d6df

📥 Commits

Reviewing files that changed from the base of the PR and between 80c20d2 and cd686c2.

📒 Files selected for processing (4)
  • frontend/src/__tests__/recommendations-lookback.test.ts
  • frontend/src/index.html
  • frontend/src/recommendations.ts
  • frontend/src/styles/components.css

Comment thread frontend/src/__tests__/recommendations-lookback.test.ts
Comment thread frontend/src/recommendations.ts
Comment thread frontend/src/recommendations.ts Outdated
)

- Block the config PUT when cachedGlobalConfig is null: if getConfig()
  failed during loadRecommendations(), falling back to {} on save would
  wipe every global setting; now toast + revert instead.
- Harden integer validation: add a /^\d+$/ guard before Number() so
  hex ("0x3c") and exponential ("7e0") strings are rejected early,
  matching the feedback_strict_int_parse repo convention.
- Add parameterised test covering 8 tampered/out-of-range inputs to
  back the docstring claim that invalid values are rejected client-side.
@cristim

cristim commented Jun 2, 2026

Copy link
Copy Markdown
Member Author

All three CR findings addressed in 31bb6a9:

CRITICAL (recommendations.ts:468/639) -- config-cache race
Removed the ?? {} fallback. The save handler now checks cachedGlobalConfig before proceeding: if it is null (getConfig() failed during load), a toast is shown, the selector reverts, and no PUT is issued.

MINOR (recommendations.ts:625) -- permissive integer check
Added a /^\d+$/ regex guard before Number() to reject hex (0x3c), exponential (7e0), leading-space, and empty strings, matching the feedback_strict_int_parse convention. The downstream Number.isInteger + LOOKBACK_OPTIONS.includes check is kept as a second stage.

MINOR (recommendations-lookback.test.ts:19) -- docstring/test mismatch
Added a test.each over 8 bad inputs (999, 0, -1, 0x3c, 7e0, ``, 7, `abc`) to back the docstring claim that tampered/out-of-range values are rejected client-side. All 17 lookback tests pass; full suite 2217/2218 (1 pre-existing skip).

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the detailed summary — all three fixes look well-targeted:

  • CRITICAL (cache race): Replacing ?? {} with an explicit cachedGlobalConfig null-guard + early-exit path is the right approach; it eliminates the phantom-save risk without relying on an empty-object fallback.
  • MINOR (strict int parse): Layering /^\d+$/ before Number() and keeping the downstream Number.isInteger + LOOKBACK_OPTIONS.includes as a second stage gives solid defence-in-depth against the malformed-input vectors.
  • MINOR (test coverage): The test.each over the 8 boundary/adversarial inputs properly backs the docstring claim and makes the rejection behaviour explicit and regression-proof.

Triggering a fresh review of commit 31bb6a97d.

(◕‿◕✿)🐇

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 40fd854 into feat/multicloud-web-frontend Jun 3, 2026
6 checks passed
@cristim
cristim deleted the feat/909-opportunities-lookback branch June 3, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/feat New capability urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant