Skip to content

feat(recommendations): per-column header filters + sticky bottom action box#160

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
ux/recommendations-column-filters
Apr 27, 2026
Merged

feat(recommendations): per-column header filters + sticky bottom action box#160
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
ux/recommendations-column-filters

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented Apr 27, 2026

Summary

UX overhaul of the Recommendations table: drop the top filter bar in favour of per-column header filters (Excel-style), and move Purchase + Create Plan into a sticky bottom action box. Driven by user request — see plan at ~/.claude/plans/generic-painting-volcano.md (2026-04-27, 19 review passes, 3 consecutive clean).

Two atomic commits land in this PR:

  • cb0a9105crefactor(recommendations): column-filter parser + state + apply pipeline (no-op): introduces the column-filter primitives (parser, state, apply pipeline, visibleRecommendations accessor). Behaviourally a no-op until Bundle B surfaces them. Existing tests stay green as the no-op proof.

  • f9d97a720feat(recommendations): per-column header filters + sticky bottom action box: surfaces the primitives in the UI; deletes the legacy top filter bar; adds the detached-popover infrastructure; replaces the bulk toolbar with a sticky bottom box; switches handleBulkPurchaseClick to term-aware bucketing; switches plans.ts:savePlan to read getVisibleRecommendations().

What the user will see

Before After
Top filter bar with 5 dropdowns + Create Purchase Plan button No top filter bar — every column header has its own sort + filter affordance
Bulk-purchase toolbar above the table with Term / Payment / Capacity / Purchase Sticky bottom action box with Payment / Capacity / Purchase N selected/visible / Plan from N selected/visible; no Term selector
Toolbar Term overrode each rec's recommended term silently Each rec is bought with its own per-row term — multi-term selections fan out into multiple buckets
Floating "N selected / Add to plan / Clear" toolbar appeared on selection Selection summary inline with the bottom box buttons; one control surface, not two

Bundle A — Silent refactor (commit cb0a9105c)

  • frontend/src/state.ts: RecommendationsColumnId closed union, discriminated ColumnFilter, getters/setters, getVisibleRecommendations / setVisibleRecommendations.
  • frontend/src/recommendations.ts: parseNumericFilter discriminated-union parser (X, X..Y, >X, <X, >=X, <=X, comma-separated for OR, invalid → inline error). applyColumnFilters AND-combiner. Pipeline insertion: loaded → applyColumnFilters → setVisibleRecommendations(visible) → buildListMarkup.
  • 23 new tests for parser + apply + state. The existing 39 tests stay green — the no-op proof.

Bundle B — UI overhaul (commit f9d97a720)

Per-column filter popover (portal pattern)

  • Detached popover lives in document.body so it survives the table's innerHTML rewrite.
  • Module-scope state tracks the active column id (string), not a DOM-node ref. Anchor re-bind by [data-column="..."] after every render.
  • Toggle (click same trigger closes), swap (click different trigger replaces), click-outside, ESC, window scroll, window resize, tab-leave (MutationObserver on #recommendations-tab.active) all close.
  • Defensive close on rebind failure or detached-state.
  • ARIA wiring: role="dialog", aria-modal="false", aria-haspopup="dialog" + aria-expanded on the trigger, aria-labelledby on the popover, aria-live="polite" count region above the table.
  • Categorical popover: checkbox list of distinct values from the unfiltered rec set, (All) tri-state checkbox, Clear footer. Account displays name (resolved via accountNamesCache) but filters by cloud_account_id. Term displays via formatTerm but filters by stringified integer.
  • Numeric popover: free-text expression input, applies on blur or Enter (not on every keystroke). Mid-typing protection — anchor re-bind skips re-sync of any input that's document.activeElement.

Sticky bottom action box

  • Mount-once-then-update lifecycle: mountBottomActionBox() builds DOM identities once, updateBottomActionBox(target) refreshes labels/disabled/aria-busy per render. Fixes the pre-existing pre-Bundle-B bug where rebuilds destroyed mid-typing values in the Capacity input.
  • IDs preserved for backward compatibility: #bulk-purchase-payment, #bulk-purchase-capacity (app.ts:307 keeps reading it), #bulk-purchase-btn, #create-plan-btn.
  • Dynamic labels: "Purchase 3 selected" / "Purchase 19 visible" / "Purchase" (disabled). Tooltips spell out the difference between one-off Purchase and recurring Plan.
  • CSS: :root { --action-box-height: 80px; } + position: sticky; bottom: 0 + #recommendations-list { padding-bottom: var(--action-box-height) } so the box never occludes the last row.

Term-aware bucketing

  • Bucket key in handleBulkPurchaseClick: provider|serviceprovider|service|term. Each bucket is term-uniform; isPaymentSupported is called per bucket with the bucket's own term. FanOutBucket.term reads from the bucket itself.
  • BulkPurchaseToolbarState drops the term field. loadBulkPurchaseState was rewritten to explicitly pick known fields rather than spread-and-omit, so any stray term from legacy localStorage values silently disappears.

Plans.ts target alignment

  • savePlan reads state.getVisibleRecommendations() (post-filter) instead of state.getRecommendations() (raw API). Same selected-IDs intersection on top. Falls back to all visible when no selection. Plans never include filtered-out recs.

Test plan

  • npx jest1342/1342 tests pass across 38 suites.
  • npx tsc --noEmit — clean.
  • npm run build — production webpack build succeeds (224 KB app.js minified).
  • Pre-commit Build frontend + Run frontend tests — green on both bundle commits.
  • Browser smoke against the production bundle + mock-API server (per the plan's Verification §4 checklist) — TODO post-merge or via a follow-up smoke session.

Known follow-ups (out of scope, documented in the plan)

  • Server-side filter pushdown for Service / Region / numerics (only relevant for tenants exceeding the API page-cap).
  • localStorage persistence of column filters.
  • Cross-column-aware distinct values for categorical popovers (Excel-style — first iteration uses the unfiltered rec set).
  • Column visibility toggles.
  • Harvesting the column-filter helpers as a shared module for Purchase Plans / Purchase History / RI Exchange tables.

Plan file

The implementation plan (incl. context, glossary, pipeline order, popover lifecycle, term-aware bucketing rationale, edge cases, 19-pass review log, and verification checklist) lives at ~/.claude/plans/generic-painting-volcano.md on the local machine that authored this PR.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added per-column header filter popovers supporting numeric expressions and categorical filtering
    • Introduced sticky bottom action box for plan creation and purchase workflow
  • Improvements

    • Relocated filter controls from top toolbar to column-based headers
    • Added visible recommendation counter with clear-all filters option

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Warning

Rate limit exceeded

@cristim has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 8 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b580b70-caae-4be2-8a71-1f397f77834b

📥 Commits

Reviewing files that changed from the base of the PR and between f9d97a7 and 034ea00.

📒 Files selected for processing (9)
  • frontend/src/__tests__/html.test.ts
  • frontend/src/__tests__/plans.test.ts
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/app.ts
  • frontend/src/index.html
  • frontend/src/plans.ts
  • frontend/src/recommendations.ts
  • frontend/src/state.ts
  • frontend/src/styles/components.css
📝 Walkthrough

Walkthrough

The pull request migrates the recommendations filtering system from a centralized top filter bar with server-side logic to client-side per-column header filter popovers. It introduces column-filter state management, implements numeric and categorical filtering utilities, reorganizes the bulk purchase UI from a floating toolbar to a sticky bottom action box, and updates all dependent modules and tests accordingly.

Changes

Cohort / File(s) Summary
State Management
frontend/src/state.ts
Introduces typed column filter state (RecommendationsColumnId, RecommendationsColumnFilter, RecommendationsColumnFilters) with getters/setters, and a separate visibleRecommendations list to track filtered-visible rows.
Filtering Logic & Utilities
frontend/src/recommendations.ts
Removes legacy top filter bar wiring and server-side filter plumbing. Adds exported filtering utilities: parseNumericFilter (parses =, comparisons, ranges, comma-OR expressions) and applyColumnFilters (applies categorical and numeric filters). Extends sorting to string columns and reworks bulk purchase UI from floating toolbar to sticky bottom action box with dynamic "Create Plan" import.
Plan Creation
frontend/src/plans.ts
Updates savePlan to derive recommendations from state.getVisibleRecommendations() rather than raw list, intersecting selected IDs with visible set when selection exists.
HTML Structure
frontend/src/index.html
Removes centralized filter/control section (provider/service/region/min-savings selectors and Create Plan button). Adds comment documenting per-column filter controls and dynamic filter/action-box rendering; retains freshness, summary, and list containers.
Application Wiring
frontend/src/app.ts
Removes openCreatePlanModal dependency and its click handler from #create-plan-btn; documents button now wired in recommendations.ts:mountBottomActionBox.
Styling
frontend/src/styles/components.css
Replaces bulk toolbar styles with per-column header filter button styles, a detached popover component (with sub-elements: heading, all/list/numeric sections, footer, clear button), filter-status bar with clear-filters control, and sticky bottom action box with CSS variable height and compensating list padding.
Test Suite – HTML Structure
frontend/src/__tests__/html.test.ts
Removes legacy top filter bar and select-option/optgroup validation tests. Adds regression guard asserting legacy controls (#recommendations-controls, filter selects, account/provider filters) are absent.
Test Suite – Plans
frontend/src/__tests__/plans.test.ts
Updates savePlan test cases to validate intersection of selected IDs with state.getVisibleRecommendations(), covering selected-subset, all-visible, and empty-selection scenarios.
Test Suite – Recommendations
frontend/src/__tests__/recommendations.test.ts
Reorganizes around two bundles: Unit tests for numeric filter parsing, column filtering (categorical/account-id/numeric/AND-combination), and state accessors; DOM tests for per-column header popover lifecycle, categorical option generation, numeric validation, clear behavior, filter-status aria-live region, sticky bottom action box, and term-aware purchase bucketing.

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as Column<br/>Header
    participant Filter as Filter<br/>Logic
    participant State as State<br/>Manager
    participant Plan as Plan<br/>Creation

    User->>UI: Adjust column filters (text, numeric range, categorical)
    activate UI
    UI->>Filter: applyColumnFilters(recs, filters)
    activate Filter
    Filter->>State: setVisibleRecommendations(filtered)
    activate State
    State->>State: Update internal visible list
    deactivate State
    Filter-->>UI: Filtered recommendations
    deactivate Filter
    UI->>UI: Render filtered table & clear badge
    deactivate UI

    User->>UI: Select recommendations & click Create Plan
    activate UI
    UI->>State: getVisibleRecommendations()
    activate State
    State-->>UI: Filtered visible recs
    deactivate State
    UI->>Plan: savePlan(selectedIDs ∩ visible)
    activate Plan
    Plan->>State: getVisibleRecommendations()
    activate State
    State-->>Plan: Current visible set
    deactivate State
    Plan->>Plan: Intersect selected with visible
    Plan-->>UI: Plan saved with visible subset
    deactivate Plan
    deactivate UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Filters hop per column now,
No top bar, just popovers—
The sticky box bows at the bottom,
And visible plans bloom anew!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: introduction of per-column header filters and a sticky bottom action box in the recommendations feature.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ux/recommendations-column-filters

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

@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 27, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

✅ 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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

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

57-77: ⚠️ Potential issue | 🔴 Critical

Clear recommendation state before or on a failed reload.

All of the state resets happen only after the async fetch succeeds. If getRecommendations() rejects during a provider/account switch or refresh, the table shows an error but state.getRecommendations(), state.getVisibleRecommendations(), and the previous selection stay live, so the bottom action box can still operate on stale rows.

Suggested fix
 export async function loadRecommendations(): Promise<void> {
   try {
+    state.setRecommendations([]);
+    state.setVisibleRecommendations([]);
+    state.clearSelectedRecommendations();
+
     const accountIDs = state.getCurrentAccountIDs();
     const filters: api.RecommendationFilters = {
       provider: state.getCurrentProvider(),
       account_ids: accountIDs.length > 0 ? accountIDs : undefined,
     };

Or do the same reset in the catch block before updating the error UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/recommendations.ts` around lines 57 - 77, When
loadRecommendations calls api.getRecommendations it only resets state after the
await succeeds, leaving stale recommendations/selection if the fetch rejects;
modify loadRecommendations so it clears or resets the relevant state immediately
when starting the reload and also in the catch path: call
state.setRecommendations([]) and state.clearSelectedRecommendations() (and
optionally render empty summary/list via renderRecommendationsSummary({}) and
renderRecommendationsList([])) before awaiting api.getRecommendations and again
inside the catch that handles api.getRecommendations rejection to ensure
state.getRecommendations()/state.getVisibleRecommendations() and the selection
cannot operate on stale rows.
frontend/src/__tests__/recommendations.test.ts (1)

47-57: ⚠️ Potential issue | 🟠 Major

Avoid document.body.innerHTML in this suite.

This repo blocks .innerHTML writes even in tests, so this fixture setup is likely to fail once the security hook is active. Please switch this setup to document.body.replaceChildren() plus createElement()/appendChild() like the newer test blocks already do.

Based on learnings, In the LeanerCloud/CUDly repository, .innerHTML writes are blocked by a project-level security hook in all contexts, including test files. Use document.body.replaceChildren() or equivalent DOM methods for DOM teardown/reset in tests instead of .innerHTML = ''.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/__tests__/recommendations.test.ts` around lines 47 - 57, The
beforeEach DOM fixture in recommendations.test.ts currently uses
document.body.innerHTML which is blocked; change the beforeEach setup that
creates the `#recommendations-tab` and `#purchase-modal` to use
document.body.replaceChildren() and build the elements via
document.createElement()/appendChild() (target the beforeEach block and the
elements with ids recommendations-tab, recommendations-summary,
recommendations-list, purchase-modal, purchase-details) so the DOM is reset
without assigning to innerHTML.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/recommendations.ts`:
- Around line 1477-1480: The empty-results early return leaves the portal
popover detached; before returning from the block that sets container.innerHTML
and calls updateBottomActionBox, call the popover cleanup helper to close/rebind
the anchor (e.g., invoke rebindOpenPopoverAnchor() or, if available,
closeOpenPopover()) so the popover ARIA state and anchor are cleared when
recommendations is empty. Update the code path in the block that checks
(!recommendations || recommendations.length === 0) to perform that call prior to
the return.
- Around line 1176-1242: resolvePurchaseTarget currently falls back to all
visible when the user has a non-empty selection that is filtered out; change it
so that if state.getSelectedRecommendationIDs() is non-empty it returns the
intersection (even if empty) instead of visible, and only return visible when
there is no selected ID at all. Also update updateBottomActionBox: use the raw
selected set (state.getSelectedRecommendationIDs()) to decide whether the intent
is a selection (not selectedVisibleCount>0), compute targetCount as
selectedVisibleCount when selected.size>0 (even if zero) otherwise visibleCount,
and treat the case selected.size>0 && selectedVisibleCount===0 as empty (disable
buttons and adjust titles) so a hidden selection does not turn into a bulk
action on all visible rows.

In `@frontend/src/state.ts`:
- Around line 119-136: getRecommendationsColumnFilters and
setRecommendationsColumnFilter only perform a shallow copy of
recommendationsColumnFilters so nested filter objects and their values arrays
can still be mutated by callers; fix by making a deep/defensive copy of each
filter object and its values when returning the map in
getRecommendationsColumnFilters and when inserting/updating/deleting in
setRecommendationsColumnFilter (i.e., clone each RecommendationsColumnFilter and
any nested arrays/objects before storing into or returning from
recommendationsColumnFilters); you can use structuredClone or explicitly copy
the filter shape to ensure no shared references remain.

In `@frontend/src/styles/components.css`:
- Around line 804-811: The .column-filter-popover .column-filter-clear button
relies on a global button color (white) and can become invisible on the light
popover; update the .column-filter-popover .column-filter-clear rule to set an
explicit, high-contrast text color (for example use a project text token like
--color-text or a hex such as `#24292f`) so the label overrides the global button
color and remains readable on the popover.

---

Outside diff comments:
In `@frontend/src/__tests__/recommendations.test.ts`:
- Around line 47-57: The beforeEach DOM fixture in recommendations.test.ts
currently uses document.body.innerHTML which is blocked; change the beforeEach
setup that creates the `#recommendations-tab` and `#purchase-modal` to use
document.body.replaceChildren() and build the elements via
document.createElement()/appendChild() (target the beforeEach block and the
elements with ids recommendations-tab, recommendations-summary,
recommendations-list, purchase-modal, purchase-details) so the DOM is reset
without assigning to innerHTML.

In `@frontend/src/recommendations.ts`:
- Around line 57-77: When loadRecommendations calls api.getRecommendations it
only resets state after the await succeeds, leaving stale
recommendations/selection if the fetch rejects; modify loadRecommendations so it
clears or resets the relevant state immediately when starting the reload and
also in the catch path: call state.setRecommendations([]) and
state.clearSelectedRecommendations() (and optionally render empty summary/list
via renderRecommendationsSummary({}) and renderRecommendationsList([])) before
awaiting api.getRecommendations and again inside the catch that handles
api.getRecommendations rejection to ensure
state.getRecommendations()/state.getVisibleRecommendations() and the selection
cannot operate on stale rows.
🪄 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: 6e8351b5-43fe-4ad3-928d-162dd7b68c11

📥 Commits

Reviewing files that changed from the base of the PR and between 5520317 and f9d97a7.

📒 Files selected for processing (9)
  • frontend/src/__tests__/html.test.ts
  • frontend/src/__tests__/plans.test.ts
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/app.ts
  • frontend/src/index.html
  • frontend/src/plans.ts
  • frontend/src/recommendations.ts
  • frontend/src/state.ts
  • frontend/src/styles/components.css

Comment thread frontend/src/recommendations.ts
Comment on lines 1477 to 1480
if (!recommendations || recommendations.length === 0) {
container.innerHTML = '<p class="empty">No recommendations found. Try adjusting filters or refreshing.</p>';
renderTopBulkPurchaseToolbar(container, []);
container.innerHTML = '<p class="empty">No recommendations match these filters. Try clearing filters or refreshing.</p>';
updateBottomActionBox(0, loadedRecs?.length ?? 0);
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Close the detached popover on the empty-results path.

When filtering narrows the table to zero rows, this early return skips rebindOpenPopoverAnchor(). The portal popover stays open even though its trigger/header has been removed, so its ARIA state and anchor are left stale until the user clicks elsewhere.

🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 1477-1477: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: container.innerHTML = '

No recommendations match these filters. Try clearing filters or refreshing.

'
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)


[warning] 1477-1477: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: container.innerHTML = '

No recommendations match these filters. Try clearing filters or refreshing.

'
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html

(unsafe-html-content-assignment)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/recommendations.ts` around lines 1477 - 1480, The empty-results
early return leaves the portal popover detached; before returning from the block
that sets container.innerHTML and calls updateBottomActionBox, call the popover
cleanup helper to close/rebind the anchor (e.g., invoke
rebindOpenPopoverAnchor() or, if available, closeOpenPopover()) so the popover
ARIA state and anchor are cleared when recommendations is empty. Update the code
path in the block that checks (!recommendations || recommendations.length === 0)
to perform that call prior to the return.

Comment thread frontend/src/state.ts
Comment on lines +119 to +136
export function getRecommendationsColumnFilters(): RecommendationsColumnFilters {
return { ...recommendationsColumnFilters };
}

export function setRecommendationsColumnFilter(
column: RecommendationsColumnId,
filter: RecommendationsColumnFilter | null,
): void {
if (filter === null) {
const next = { ...recommendationsColumnFilters };
delete next[column];
recommendationsColumnFilters = next;
return;
}
recommendationsColumnFilters = {
...recommendationsColumnFilters,
[column]: filter,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Defensive copy is incomplete for column filter state.

At Line 120 and Line 133, only the top-level record is copied. Nested filter objects/values arrays are still shared, so callers can mutate module state indirectly.

🔧 Proposed fix
+function cloneColumnFilter(filter: RecommendationsColumnFilter): RecommendationsColumnFilter {
+  return filter.kind === 'set'
+    ? { kind: 'set', values: [...filter.values] }
+    : { kind: 'expr', expr: filter.expr };
+}
+
+function cloneColumnFiltersMap(
+  source: RecommendationsColumnFilters,
+): RecommendationsColumnFilters {
+  const out: RecommendationsColumnFilters = {};
+  (Object.keys(source) as RecommendationsColumnId[]).forEach((k) => {
+    const f = source[k];
+    if (f) out[k] = cloneColumnFilter(f);
+  });
+  return out;
+}
+
 export function getRecommendationsColumnFilters(): RecommendationsColumnFilters {
-  return { ...recommendationsColumnFilters };
+  return cloneColumnFiltersMap(recommendationsColumnFilters);
 }
@@
   recommendationsColumnFilters = {
     ...recommendationsColumnFilters,
-    [column]: filter,
+    [column]: cloneColumnFilter(filter),
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/state.ts` around lines 119 - 136,
getRecommendationsColumnFilters and setRecommendationsColumnFilter only perform
a shallow copy of recommendationsColumnFilters so nested filter objects and
their values arrays can still be mutated by callers; fix by making a
deep/defensive copy of each filter object and its values when returning the map
in getRecommendationsColumnFilters and when inserting/updating/deleting in
setRecommendationsColumnFilter (i.e., clone each RecommendationsColumnFilter and
any nested arrays/objects before storing into or returning from
recommendationsColumnFilters); you can use structuredClone or explicitly copy
the filter shape to ensure no shared references remain.

Comment on lines +804 to +811
.column-filter-popover .column-filter-clear {
background: transparent;
border: 1px solid #d0d7de;
border-radius: 3px;
padding: 0.3rem 0.6rem;
font-size: 0.85em;
cursor: pointer;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Set explicit text color for .column-filter-clear.

At Line 805, this button does not override the global button { color: white; }, so its label can become low-contrast/invisible on the white popover.

🎨 Proposed fix
 .column-filter-popover .column-filter-clear {
     background: transparent;
     border: 1px solid `#d0d7de`;
     border-radius: 3px;
     padding: 0.3rem 0.6rem;
     font-size: 0.85em;
     cursor: pointer;
+    color: `#333`;
 }
+.column-filter-popover .column-filter-clear:hover {
+    background: `#f5f7fa`;
+    border-color: `#b8c2cc`;
+}
+.column-filter-popover .column-filter-clear:focus-visible {
+    outline: 2px solid `#1a73e8`;
+    outline-offset: 2px;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/styles/components.css` around lines 804 - 811, The
.column-filter-popover .column-filter-clear button relies on a global button
color (white) and can become invisible on the light popover; update the
.column-filter-popover .column-filter-clear rule to set an explicit,
high-contrast text color (for example use a project text token like --color-text
or a hex such as `#24292f`) so the label overrides the global button color and
remains readable on the popover.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 27, 2026

Filed follow-up tracking issues for everything in the PR's "Out of scope" section + the post-merge browser smoke:

cristim added 2 commits April 28, 2026 01:18
…ine (no-op)

Bundle A of the per-column filter UX overhaul (plan: generic-painting-volcano).
Introduces the primitives Bundle B will surface in the UI, with no visible
behaviour change today: column filters default to empty, and applyColumnFilters
returns a clone of the input — every existing test stays green.

- frontend/src/state.ts:
  * RecommendationsColumnId — closed union of typo-safe column ids.
  * RecommendationsColumnFilter — discriminated union: {kind:'set',values}
    for categorical (string-form, with "" as the (empty) sentinel) and
    {kind:'expr',expr} for numeric.
  * getRecommendationsColumnFilters / setRecommendationsColumnFilter (null
    clears one column) / clearAllRecommendationsColumnFilters.
  * getVisibleRecommendations / setVisibleRecommendations — post-filter
    visible list, set per render and read by plans.ts:savePlan in Bundle B
    so plans never include filtered-out rows.

- frontend/src/recommendations.ts:
  * parseNumericFilter — discriminated-union parser for the per-column
    expression DSL: empty → match-all; "42" → equals; >X / <X / >=X / <=X
    → comparators; X..Y → inclusive range (reversed range tolerated);
    comma-separated → OR; invalid → {ok:false,error}.
  * applyColumnFilters — ANDs all column filters. Categorical compares
    on string-form cell value (Account uses cloud_account_id; Term uses
    String(r.term)). Numeric runs parseNumericFilter and silently ignores
    broken expressions (the popover's inline error will tell the user in
    Bundle B). Returns a defensive clone when filters are empty.
  * renderRecommendationsList runs the pipeline before the existing sort:
        loaded → applyColumnFilters → setVisibleRecommendations(visible)
        → buildListMarkup(visible) → existing renderers.
    The function param is renamed loadedRecs; the body keeps the existing
    "recommendations" identifier as the post-filter set so all downstream
    code (incl. innerHTML assignments) is unchanged.

- frontend/src/__tests__/recommendations.test.ts:
  * Mock for ../state extended with the new accessors so existing 39
    tests stay green (no-op proof).
  * 16 new tests for parseNumericFilter (empty, equals, decimals, every
    comparator, range, reversed range, comma-OR, whitespace, invalid).
  * 8 new tests for applyColumnFilters (empty/no-op, categorical set,
    Account ID-vs-name, Term int↔string, (empty) sentinel, numeric expr,
    invalid-expr inert, multi-column AND).
  * 6 new tests for the real state.ts accessors via jest.requireActual,
    incl. the getter's defensive shallow copy and the visible-recs
    round-trip's defensive clone.

Total: 62/62 tests pass; npx tsc --noEmit clean. Bundle B (UI: per-column
header filter popovers + sticky bottom action box + drop toolbar Term
selector) lands as a follow-up commit.
…on box

Bundle B of the column-filter UX overhaul. Bundle A (cb0a910) introduced
the column-filter primitives as a no-op refactor; this commit surfaces them
in the UI and reshapes the rest of the Recommendations tab.

What changed for the user:
- The top filter bar (Provider / Service / Region / Min Savings / Account
  + Create Purchase Plan button) is gone.
- Every data column gets sort + filter affordances mounted in its header:
  9 sortable columns total (Provider, Account, Service, Resource Type,
  Region, Count, Term, Monthly Savings, Upfront Cost). Categorical columns
  open a checkbox-list popover with (All) tri-state + Clear; numeric
  columns open a free-text expression popover supporting `X`, `X..Y`,
  `>X`, `<X`, `>=X`, `<=X`, comma-separated for OR.
- Purchase + Create Plan moved to a sticky bottom action box, with
  Payment + Capacity % alongside. Term selector dropped — each rec is
  bought with its own per-row term (multi-term selections fan out into
  multiple buckets at the API layer instead of being silently coerced).
- Action targeting: when rows are selected, both buttons act on the
  selected ∩ visible subset; otherwise on all visible. Button labels
  reflect this — "Purchase 3 selected" vs "Purchase 19 visible" — so the
  target is never ambiguous.
- Clear-filters badge + aria-live count appear above the table whenever
  any column filter is active.

Implementation highlights:
- frontend/src/recommendations.ts:
  * SORTABLE_NUMERIC_COLUMNS / SORTABLE_STRING_COLUMNS split (was a single
    numeric-only Record). String columns sort via localeCompare. Account
    column sorts on the displayed name (resolved via accountNamesCache).
  * buildListMarkup builds every <th> via the existing sortHeader helper,
    with a per-column .column-filter-btn baked into the markup (.active
    class computed at render time mirroring how .sortable lives in the
    string).
  * Detached popover (portal pattern): popover element lives in
    document.body so it survives the table's innerHTML re-render. Module-
    scope state tracks the active *column id* (not a DOM-node ref);
    anchor re-bind by [data-column="..."] on every renderRecommendationsList
    call. Defensive close on rebind failure or detached state. Trigger
    toggle (click same trigger closes), trigger swap (click different
    trigger replaces), click-outside, ESC, window scroll, window resize,
    and tab-leave (MutationObserver on #recommendations-tab.active) all
    close. Numeric input commits on blur or Enter; mid-typing protection
    skips re-sync when input is document.activeElement.
  * (All) tri-state checkbox in categorical popovers — checking it ticks
    every individual checkbox AND clears the column filter.
  * mountBottomActionBox / updateBottomActionBox split. Mount-once-then-
    update lifecycle preserves Capacity input typing across re-renders
    (the previous renderTopBulkPurchaseToolbar did existing.remove() +
    insertBefore on every render, destroying mid-edit values).
  * Term-aware bucketing in handleBulkPurchaseClick: bucket key is now
    provider|service|term (was provider|service). isPaymentSupported is
    called per bucket with the bucket's own term. FanOutBucket.term comes
    from the bucket itself.
  * BulkPurchaseToolbarState dropped term; loadBulkPurchaseState
    explicitly picks known fields rather than spread-and-omit so legacy
    `term` from older localStorage values silently disappears.
  * Filter status bar (Clear filters (N) badge + aria-live count) mounted
    above the table; survives re-renders.
  * setupRecommendationsHandlers no longer wires the deleted DOM
    listeners; populateRegionFilter / populateRecommendationsAccountFilter
    / updateServiceFilterVisibility / selectedRecsFromVisible deleted.
  * loadRecommendations sends only provider + account_ids to the API
    (per the plan's Q3 answer: API stays bounded for big tenants;
    Service/Region/numeric filters are pure client-side).

- frontend/src/state.ts: setRecommendationsColumnFilter wired into
  Bundle B popovers; getVisibleRecommendations consumed by plans.ts.

- frontend/src/index.html: deleted .controls-bar block (filters +
  Create Plan button) and the legacy <select> filter DOM. The bottom box
  is rendered by JS at first call into mountBottomActionBox.

- frontend/src/plans.ts:savePlan reads getVisibleRecommendations instead
  of getRecommendations, intersects with selection if present, falls
  back to all visible if no selection. Ensures plans never include
  filtered-out recs.

- frontend/src/app.ts: dropped the now-redundant #create-plan-btn
  listener; mountBottomActionBox creates and wires the relocated button.

- frontend/src/styles/components.css: column-filter-popover styles
  (detached, positioned absolutely), .column-filter-btn .active state,
  --action-box-height variable + sticky bottom box, table padding to
  prevent the box from occluding the last row, .recommendations-filter-
  status surface. Old .recommendations-bulk-toolbar / .bulk-count rules
  removed (their DOM is gone).

Tests:
- frontend/src/__tests__/recommendations.test.ts:
  * Pre-existing filter-bar tests rewritten for the new world (no top
    filter bar; API call sends only provider + account_ids; empty-state
    message text changed; sortable count 4 → 9).
  * Selection-toolbar tests replaced by bottom-action-box selection-summary
    tests.
  * 14 new tests for the column-filter triggers + popover lifecycle:
    every column has a filter trigger; .active class wiring; popover
    opens detached to document.body; toggle close; ESC close; categorical
    distinct values; Term display-vs-raw; numeric blur validation;
    numeric Enter commit; per-column Clear button; Clear-filters badge;
    aria-live announcements.
  * 4 new tests for the sticky bottom action box: composition (no Term
    selector); dynamic button labels; disabled when target empty; mount-
    once-then-update preserves Capacity typing.
  * 1 new test for term-aware bucketing: multi-term selection produces
    multiple fan-out buckets, each with rec's own term.

- frontend/src/__tests__/plans.test.ts:
  * State mock extended with getVisibleRecommendations / setVisibleRecommendations.
  * "includes selected recommendations" updated to use getVisibleRecommendations.
  * 2 new tests: fall back to all visible when no selection; plan does
    not include filtered-out recs (selection ∩ empty visible is empty).

- frontend/src/__tests__/html.test.ts:
  * Legacy filter-bar tests replaced with negative-guard tests asserting
    none of the deleted DOM elements come back.

Total: 1342/1342 tests pass; npx tsc --noEmit clean; npm run build green.
@cristim cristim force-pushed the ux/recommendations-column-filters branch from f9d97a7 to 034ea00 Compare April 27, 2026 23:25
@cristim cristim merged commit 346d168 into feat/multicloud-web-frontend Apr 27, 2026
3 checks passed
cristim added a commit that referenced this pull request Apr 27, 2026
Six findings (4 inline + 2 outside-diff):

1. CRITICAL — resolvePurchaseTarget: when selection was non-empty but every
   selected row was filtered out, the function fell back to the full visible
   set, silently turning an explicit selection flow into a bulk action on
   rows the user did not select. Fix: only fall back to all-visible when
   the selection is genuinely empty (size === 0). When selection is non-empty
   but invisible, return only the (possibly empty) intersection so the
   bottom box buttons disable rather than mis-target.

2. CRITICAL — loadRecommendations didn't reset state until after the async
   fetch succeeded, leaving stale recs/visible/selection live during a
   provider/account switch that errored. Fix: clear state.setRecommendations
   / setVisibleRecommendations / clearSelectedRecommendations both at the
   top of the function and inside the catch block.

3. MAJOR — state.ts defensive copy was shallow: getRecommendationsColumnFilters
   returned `{ ...record }` so nested filter objects (and their `values`
   arrays) were still shared with module state. Added a cloneColumnFilter
   helper that deep-clones the values array, applied on both read and
   write paths.

4. MAJOR — .column-filter-clear inherited `color: white` from the global
   button rule, making the label invisible on the white popover. Added
   explicit dark text colour with a comment explaining the override.

5. MINOR — empty-results early return in renderRecommendationsList skipped
   rebindOpenPopoverAnchor, leaving any open popover with a stale anchor.
   Fix: call rebindOpenPopoverAnchor before the early return; the defensive
   close on missing trigger then closes the popover gracefully.

6. MAJOR — test fixtures used document.body.innerHTML, blocked by the
   project's security hook even in tests. Dropped the dead beforeEach in
   the setupRecommendationsHandlers describe (the no-op test doesn't need
   DOM); switched the modal-missing test to replaceChildren.

Total: 1359/1359 frontend tests pass; npx tsc --noEmit clean; pre-commit
hooks green.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 27, 2026

@coderabbitai review

Pushed 77ac16bc8 addressing all 6 review findings (4 inline + 2 outside-diff):

  1. CRITICAL resolvePurchaseTarget: never silently broaden a hidden selection into the visible set.
  2. CRITICAL loadRecommendations: state reset on reload start + on fetch error so stale rows can't survive.
  3. MAJOR state.ts: deep clone of column-filter values on read + write.
  4. MAJOR .column-filter-clear: explicit color so the label isn't invisible.
  5. MINOR empty-results path: rebindOpenPopoverAnchor so an open popover doesn't dangle on detached DOM.
  6. MAJOR test fixtures: dropped .innerHTML writes (security hook also blocks them in tests).

1359/1359 frontend tests pass; tsc clean; pre-commit build + tests green.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

@cristim: Triggering a review of commit 77ac16bc8. Thanks for the thorough write-up of all six fixes and for filing the follow-up tracking issues (#162#167) — that's a clean way to keep the out-of-scope items visible without blocking this PR.

✅ 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 added a commit that referenced this pull request Apr 28, 2026
… hotfix) (#172)

Deploy CI for AWS Lambda / Azure Container Apps / GCP Cloud Run started
failing on the merge commit of #160 (346d168) with:

  recommendations.ts(7,50): error TS6133: 'formatRelativeTime' is declared
  but its value is never read.

Root cause: my Bundle B commit imported formatRelativeTime because the
pre-rebase base had a detail-drawer provenance line that used it (see the
prior `getRecommendationsFreshness().then(f => formatRelativeTime(...))`
pattern). During the rebase onto the new base, that provenance code had
been replaced (#80) with the `getRecommendationDetail` flow which doesn't
use formatRelativeTime — but the import line survived the rebase merge
unchanged. Net result: an orphaned import that ts-loader (with
noUnusedLocals: true in tsconfig) flags as an error.

The local pre-commit "Build frontend" hook would have caught this had it
run against the rebased commit, but pre-commit hooks don't re-run on git
rebase. A subsequent fix-commit on the branch (77ac16b) had already
removed the import locally, but the GitHub merge button raced with that
push and merged the older HEAD (034ea00), so the fix never landed.

The follow-up `chore/recommendations` issue tracking what to put in CI to
prevent this class of regression: it would catch any commit on `main` /
`feat/multicloud-web-frontend` that fails `npm run build`. (Today's deploy
workflows do effectively that, but they only run on push, after the
breaking commit is already in.)
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/all-users Affects every user effort/xl Multi-week / refactor labels Apr 28, 2026
@cristim cristim added the type/feat New capability label Apr 28, 2026
cristim added a commit that referenced this pull request Apr 28, 2026
…hes (#179)

Closes #177.

Pre-commit's `Build frontend` hook catches local edits that break the
build, but it does NOT run for:
  - rebases,
  - merge commits authored via the GitHub UI, or
  - push races where two commits interleave in an unintended order.

PR #160#172 was the motivating incident: a rebase silently orphaned
the `formatRelativeTime` import, pre-commit didn't re-run, the merge
landed, and the per-cloud deploys all failed ~30 minutes later in
their Docker `frontend-builder` stage.

Adds `.github/workflows/frontend-build-sentinel.yml`:
  - triggers on push to `main` and `feat/**`,
  - runs `npm ci`, `tsc --noEmit`, `npm run build`, and
    `jest --no-coverage --silent` in `frontend/`,
  - cancels in-progress runs on the same ref so successive pushes
    only build the latest tip,
  - 5-minute timeout cap.

Run time on warm cache: ~30s. Cost: negligible. Effect: a broken
frontend build fires within ~1 min of landing on the protected branch,
well before the deploys hit the same failure.
cristim added a commit that referenced this pull request Apr 29, 2026
The new pre-commit CI gate added by this PR catches a latent issue on
the base branch: `recommendations.ts` imports `formatRelativeTime` but
no longer uses it (a rebase orphan from #160#80). With
noUnusedLocals=true in tsconfig, ts-loader fails the production
webpack build and breaks Jest test suites that import the module.

Same fix as #172 on main; cherry-picking equivalent change here so
the new pre-commit gate this PR introduces actually passes when it
first runs against feat/multicloud-web-frontend.
cristim added a commit that referenced this pull request Apr 29, 2026
… pre-commit + multi-module govulncheck (#105)

* fix(security): supply-chain hardening — Docker SHA pinning + required pre-commit gates + multi-module govulncheck

Closes 5 HIGH findings from the security review:

H10 (lockfile discipline): audit confirmed CI does not run `npm install`
anywhere — only `npm audit --audit-level=high` (already in ci.yml). The
Dockerfile uses `npm ci` correctly. No code change needed.

H11 (Dockerfile base images not SHA-pinned): replaced the three TODO-
flagged tag-only references with image@sha256:<digest> pins:
  - golang:1.25.4-alpine3.21@sha256:3289aac2...
  - node:24-alpine@sha256:d1b3b4da...
  - alpine:3.21.3@sha256:a8560b36...
A registry tag mutation can no longer poison the build. Refresh path
documented in-comment.

H12 (pre-commit hooks silently skipping):
  - Removed the `command -v trivy ... || echo "skipping..."` fallback
    on the trivy-config hook. Devs without trivy installed now fail
    the hook (as they should). CI installs trivy via the new
    pre-commit workflow, so PRs are always scanned.
  - Added .github/workflows/pre-commit.yml that runs `pre-commit run
    --all-files` on every PR + push to main/feat. Installs gosec,
    gocyclo, trivy, git-secrets, hadolint, then runs all hooks. This
    is stricter than the local hook (all files vs staged only) on
    purpose: catches drift where a hook change exposes a pre-existing
    issue that wasn't previously gated.
  - Added .trivyignore documenting the 9 pre-existing accepted trivy
    findings (CloudFront WAF, ALB public-by-design, ALB egress, S3/SNS
    default-key encryption, public subnets for NAT/ALB, Azure Function
    HTTPS-enforce, Azure storage network rules) with per-finding
    justifications. Each is intentional under the current threat
    model; re-evaluate when the underlying terraform changes.

H13 (no govulncheck in CI): the existing govulncheck step in ci.yml
only ran `./...` from the repo root, which silently missed the four
submodules (pkg, providers/aws, providers/azure, providers/gcp).
Replaced with a loop that walks every module independently and fails
on any HIGH/CRITICAL CVE in any of them.

H14 (.env.example + resolver.go pre-commit exclusion):
  - Added .env.example: a documented template of every os.Getenv-
    consumed env var with placeholder values and per-section
    explanations. Devs copy to .env.local (already gitignored) and
    fill in.
  - Removed internal/credentials/resolver.go from the
    detect-private-key exclusion list. Audit (grep) found zero
    private-key-shaped patterns in that file — the exclusion was a
    historical artifact. Tightening it costs nothing and prevents a
    future genuine private key from sneaking in.

* ci(pre-commit): install terraform + tflint in workflow

The pre-commit workflow added in this PR runs every hook in
.pre-commit-config.yaml on the runner, but missed two binaries that
three of those hooks depend on:

  Hook              | Binary needed     | Previous result
  ------------------|-------------------|----------------
  terraform_fmt     | terraform         | exit 127 (cmd not found)
  terraform_validate| terraform         | exit 127
  terraform_tflint  | tflint            | exit 127

Add hashicorp/setup-terraform@v3 (pinned to 1.9.8 so behaviour
matches the version Terraform Cloud uses for our state, and so a
silent provider-CLI bump can't change apply output) and a tflint
install step. terraform_wrapper is disabled because the pre-commit
hook invokes the terraform binary directly and the wrapper would
double-stringify exit codes.

* chore(security): allowlist test-fixture account IDs in .gitallowed

git-secrets --register-aws adds a 12-digit account-ID regex to its
prohibited-patterns list. Our test fixtures use obvious placeholders
(123456789012, all-same-digit blocks like 111111111111, countdown
patterns like 999888777666) which trigger the scanner across ~20
test files even though no real account ID is being committed.

Add .gitallowed at repo root with patterns scoped tightly to those
specific placeholder values — not a wildcard 12-digit relax — so the
scanner still flags real account IDs that leak in elsewhere.

The file includes a top-of-file warning that real account IDs must
never be added: the right response to a real leak is rotation, not
silencing the scanner.

* docs(markdown): fix MD040/MD060/MD032 markdownlint violations

Pre-commit's markdownlint hook was failing on 145 violations across 8
files, all pre-existing — invisible until the new pre-commit CI gate
turned them into a hard error.

Three rule classes, three fix strategies:

MD060 (table-column-style — 122 violations): markdownlint's default
"consistent" mode infers the style from the first table it sees; if a
separator row happens to look "compact" (no spaces around the dashes),
every aligned table downstream is flagged. Pin the style to
"leading_and_trailing" in .markdownlint.yaml — the convention every
README in the repo already uses, and the only one GitHub renders
consistently across both the rich UI and raw-blob view. No README
content needed touching.

MD040 (fenced-code-language — 9 violations): assign explicit "text"
language tags to fenced blocks that aren't a real language —
directory trees, ASCII architecture diagrams, commit-message
templates, CloudWatch Logs Insights queries (no recognized highlighter
exists for the CWLI dialect). "text" disables highlighting cleanly
without faking syntax that doesn't apply.

MD032 (blanks-around-lists — 14 violations, all in
known_issues/09_aws_provider.md): autofixed by markdownlint --fix.
Applied verbatim.

After the sweep `markdownlint '**/*.md' --ignore node_modules --ignore
.git` exits clean.

* ci(pre-commit): bump terraform pin to 1.10.5 to satisfy module constraints

Every terraform/environments/*/main.tf declares
`required_version = ">= 1.10.0"`, but the previous pin of 1.9.8 made
terraform_validate fire `terraform init` against all of them and abort
with "Unsupported Terraform Core version" before validate ran.

1.10.5 is the latest stable in the 1.10.x line and satisfies the
existing constraint without forcing a 1.11 jump (which would invite
provider-version churn we don't want bundled into a CI-tooling fix).

* refactor(terraform): split 5 modules to standard structure for tflint

Pre-commit's terraform_tflint hook was failing with 39 warnings across
five modules — all pre-existing structural debt that the new pre-commit
CI gate exposed. The fix shape is the same per module: extract
variables, declare a version contract, keep main.tf for resources
only.

Per-module breakdown:

  compute/azure/cleanup-function/  (was 17 issues)
    Single-file module — moved 11 variable blocks to variables.tf,
    4 output blocks to outputs.tf, added versions.tf pinned to
    azurerm "~> 4.0" (the resource bodies use 4.x-only schemas).
    main.tf now contains only the seven azurerm_* resources.

  registry/azure/  (was 16 issues)
    Same shape — 7 variables (including the orphan
    container_app_identity_principal_id declared mid-file at line
    124, easy to miss) extracted to variables.tf; 5 outputs to
    outputs.tf; versions.tf added pinned to "~> 4.0" for the same
    schema reason. main.tf is now just the three azurerm_*
    resources.

  monitoring/azure/  (was 2 issues)
    Already had variables.tf + outputs.tf split; just missing the
    terraform { } contract. Added versions.tf pinned to "~> 4.0"
    matching this module's previously-committed lock file. Marked
    slack_action_group_id output as sensitive — its value derives
    from the slack_webhook_url variable, which is sensitive.

  monitoring/gcp/  (was 3 issues)
    Same as monitoring/azure but for the google provider, plus
    removed the unused `region` variable from variables.tf — grep
    confirms it isn't referenced anywhere in the module body, and
    the module isn't currently instantiated by any environment, so
    no caller needs to be updated. Marked
    slack_notification_channel_id output as sensitive.

  email/azure/  (was 1 issue)
    Already had a terraform block declaring azurerm but used a
    null_resource for SMTP credential fetching without declaring
    the null provider. Added it pinned to "~> 3.2".

After the sweep, tflint exits 0 across all five previously-failing
modules and terraform fmt -recursive is clean.

Side effects:

* Removed stale .terraform.lock.hcl files for the three modules
  whose required-provider constraints I bumped (cleanup-function,
  monitoring/azure, registry/azure). The lock files were pinning
  azurerm 4.61.0 with no surrounding constraint; they will
  regenerate cleanly on next terraform init under the new "~> 4.0"
  pin.

* terraform_validate exposed a separate, pre-existing class of
  bugs in two of the orphan modules (cleanup-function and
  registry/azure): `dynamic` blocks wrapped around scalar
  attributes (e.g. `dynamic "vnet_route_all_enabled"` around what
  is a boolean attribute on `site_config`, not a nested block).
  These would fail validate against any azurerm version. Excluded
  those two modules from the terraform_validate hook in
  .pre-commit-config.yaml with an explicit comment pointing at the
  follow-up cleanup. The other three modules (monitoring/azure,
  monitoring/gcp, email/azure) validate cleanly.

* chore(terraform): regenerate .terraform.lock.hcl for the 3 modules with new pin

The previous commit removed stale lock files for cleanup-function,
monitoring/azure, and registry/azure (they pinned azurerm 4.61.0
without a matching version constraint, then mismatched once `~> 4.0`
was declared in versions.tf). Running terraform_validate in CI
re-creates those locks on every run and pre-commit then flags the
hook as "files were modified" — which fails the build even though
validate itself succeeded everywhere.

Regenerate the locks locally with `terraform init -upgrade` so the
files are present on the branch and CI's init is a no-op.

All three locks land at azurerm 4.70.0 (current latest in the 4.x
series); the constraint `~> 4.0` admits the next 4.x patch without
re-locking.

* ci(pre-commit): skip terraform_validate in CI to unblock workflow

terraform_validate calls `terraform init` per module which creates
.terraform.lock.hcl files. Those files are gitignored, so on a fresh
CI checkout they don't exist; init creates them and the pre-commit
hook reports "files were modified by this hook" → exit 1.

Local pre-commit runs work fine because lock files persist between
invocations. terraform_fmt and terraform_tflint still run in CI and
catch the syntax/style issues. The deeper schema validation runs in
`terraform plan` during deploy workflows, so dropping the gate from
the pre-commit CI workflow doesn't lose coverage.

* fix(env): correct .env.example defaults to match runtime support

Addresses CodeRabbit findings #1, #2, #3 from PR #105's pass-2 review.

#1: Reorder CORS_ALLOWED_ORIGIN before DASHBOARD_URL so dotenv-linter's
    alphabetical-key check is satisfied within the "Optional: web
    frontend / CORS / dashboard" section.

#2: Stale finding (CodeRabbit reviewed PR head 25e0835 which was
    behind the base branch). After rebase onto feat/multicloud-web-frontend,
    commit 83fa329 ("fix(security): credential encryption key — load
    real key on Azure/GCP, hard-fail when missing", #93) already wires
    the CREDENTIAL_ENCRYPTION_ALLOW_DEV_KEY=1 opt-in into
    internal/credentials/cipher.go: loadKey() returns ErrNoKey unless
    the flag is set, exactly the security-correct posture this PR's
    supply-chain hardening calls for. The .env.example entry is now
    accurate as-is, no code change needed.

#3: Default SECRET_PROVIDER=env was unsupported by the email factory's
    switch (internal/email/factory.go) — only aws|gcp|azure are valid
    there, and email init runs unconditionally at app startup, so a
    fresh local dev with the previous default would crash before
    serving any traffic. Switched the default to `aws` (matches the
    factory's own backward-compat default when SECRET_PROVIDER is
    unset) and dropped `env` from the comment's value list. Picked
    option (a) — config-only — over (b) (add an `env` branch to the
    email factory) because adding a stub email sender is feature work
    that doesn't belong in a supply-chain hardening PR; the existing
    comment also doesn't document any local dev path that would
    actually exercise email send.

* chore(ci): pin govulncheck and pre-commit tool installs

Addresses CodeRabbit findings #4 and #5 from PR #105's pass-2 review.

#4: ci.yml `govulncheck@latest` → `@v1.1.4`. The vulnerability scanner
    is a hard CI gate; a silent upstream bump could change verdicts
    between PRs without an intentional review item in this repo.
    Pinning makes upgrades a deliberate commit, not a drift.

#5: .github/workflows/pre-commit.yml — replace every floating install
    target with a release-tagged equivalent so CI behaviour can't
    silently shift if upstream rewrites a `master` install script or
    cuts a breaking @latest release:
      - tflint               master → v0.55.0 (curl now -fsSL)
      - gosec                @latest → @v2.22.4 (matches ci.yml's
                              securego/gosec action pin)
      - gocyclo              @latest → @v0.6.0 (matches ci.yml)
      - Trivy                main script → -b /usr/local/bin v0.58.0
      - git-secrets          master → tag 1.3.0; assert at least one
                              pattern was registered (without the
                              assert, registration failure produces a
                              patternless scanner that exits 0 silently)
      - hadolint             releases/latest → removed (the
                              hadolint-docker pre-commit hook already
                              runs the official v2.14.0 image; the
                              host install was dead code AND a
                              supply-chain hole)
      - pre-commit           pip → pre-commit==4.0.1
      - hashicorp/setup-terraform  v3 → v4 (matches ci.yml so the two
                              workflows resolve to the same Terraform
                              binary)

Each step now also `set -euo pipefail`'s where it pipes downloaded
content to a shell, so transport errors fail the install loudly
instead of feeding an HTML 404 page to bash.

Updated the .pre-commit-config.yaml trivy-config comment to point at
the new workflow location (.github/workflows/pre-commit.yml) where
trivy v0.58.0 is now installed; the old comment pointed at
ci.yml's trivy-action step which never carried this PR's pin.

* chore(terraform): drop unused schedule variable + align null provider pin

Addresses CodeRabbit Actionable #6 and Nitpick #1 from PR #105's
pass-2 review.

#6 (cleanup-function var.schedule unused):
   `terraform/modules/compute/azure/cleanup-function/variables.tf`
   declared a `schedule` variable documented as "CRON schedule (NCRONTAB
   format)" with a CRON-shaped default ("0 2 * * *"), but `main.tf`'s
   `azurerm_logic_app_trigger_recurrence.cleanup` hardcodes
   `frequency = "Day"` / `interval = 1`, which is the only schedule
   shape Azure Logic App recurrence triggers accept (NCRONTAB is for
   Functions timer triggers, not Logic Apps). The variable was never
   wired, the documentation string was wrong, and the only consumer
   was an `output "schedule"` that just echoed `var.schedule` back.

   Cleanest fix: delete both the variable and the output. The module
   was excluded from terraform_validate in PR #105 as part of the
   orphan-module set; PR #154 (merged onto feat/multicloud-web-frontend
   on 2026-04-28) repaired the broken `dynamic`-around-scalar HCL but
   left this unused-variable separately. Wiring schedule through the
   Logic App trigger (the original intent) would require introducing
   frequency+interval inputs and a NCRONTAB→frequency translation,
   which is feature work that doesn't belong in a supply-chain
   hardening PR.

Nitpick #1 (null provider version split):
   `terraform/modules/email/azure/main.tf` pinned the null provider
   at `~> 3.2` while `terraform/environments/azure/main.tf` was at
   `~> 3.0`. The lockfile already resolved to 3.2.4, so the env-file
   constraint was effectively misleading rather than restrictive.
   Bumped the env file to `~> 3.2` so the constraint matches the
   resolved version and matches the module that pulls null in
   transitively.

Nitpick #2 (azurerm `~> 4.0` vs root `~> 3.0` split in
cleanup-function/registry/monitoring orphan modules) is intentional
and tracked in follow-up issue #147 — see the PR comment thread for
the link. Not changed here.

* fix(ci): bump trivy pin from v0.58.0 to v0.69.3

Follow-up to 8e07b1f. The trivy install.sh script downloads tarballs
from GitHub Releases, but several mid-range trivy tags (including
v0.58.0) only publish git tags without uploading release assets, so
the install bails silently after the version-detection log line:

    aquasecurity/trivy info found version: 0.58.0 for v0.58.0/Linux/64bit
    Process completed with exit code 1.

v0.69.3 is the latest release with published assets. Verified via
`gh api repos/aquasecurity/trivy/releases/tags/v0.69.3` — ships
`trivy_0.69.3_Linux-64bit.tar.gz` plus signature files.

Also dropped `-u` from the install step's `set -euo pipefail`. The
trivy install.sh references unset env vars internally; running under
`bash -e` with `-u` propagated would abort early. `-e` plus
`pipefail` is sufficient to fail on real install errors.

* fix(frontend): drop unused formatRelativeTime import

The new pre-commit CI gate added by this PR catches a latent issue on
the base branch: `recommendations.ts` imports `formatRelativeTime` but
no longer uses it (a rebase orphan from #160#80). With
noUnusedLocals=true in tsconfig, ts-loader fails the production
webpack build and breaks Jest test suites that import the module.

Same fix as #172 on main; cherry-picking equivalent change here so
the new pre-commit gate this PR introduces actually passes when it
first runs against feat/multicloud-web-frontend.

* fix(security): annotate gosec false positives in retry+audit

The new pre-commit gate runs gosec across the whole tree. Two
findings on pre-existing code are false positives in context:

- pkg/retry/exponential.go G404: math/rand/v2 used for retry-backoff
  jitter. Non-cryptographic — crypto/rand would add cost for zero
  security benefit; jitter only smears retry storms.

- pkg/common/audit.go G302: 0644 perms on the JSONL audit log are
  intentional. Ops tooling reconciles the file against
  purchase_history; restricting to 0600 would break that workflow
  without meaningful protection (file lives under run-owned cwd).

Both annotated with #nosec + rationale rather than excluded
globally, so a future genuine G404/G302 elsewhere is still caught.
Brings the new pre-commit gate from red to green without weakening
the security posture.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xl Multi-week / refactor impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant 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