Skip to content

Conversation

@alexandrabain
Copy link

@alexandrabain alexandrabain commented Nov 5, 2025

Changes:
[Note dependent on https://github.com/truenas/middleware/pull/17514]

  1. Section to display Non-TrueNAS deployed app i.e., apps deployed through tools such as Dockge or through Docker CLI
  • Includes display only, no lifecycle management of these Apps
  • Does Include Resources
  1. Added header row for the TrueNAS managed Apps
  2. Added collapse ability on the section headers
  3. Added overall Apps Utilization Row.
image image

Testing:

Tested new and upgraded deployments, with a variety of apps in both categories.
Tested existing controls for TrueNAS Managed apps remain functional
Tested sorting of application names worked in each group
Tested filtering works across each group.

Downstream

Affects Reasoning
Documentation Revised UI with a new section for Other Apps and with a collapsible section
Testing Need to expand to include having apps deployed means other than just the TrueNAS Managed option to test the table - recommend Auto tests.

alexandrabain and others added 15 commits October 27, 2025 14:45
- Add 'source' field to App TypeScript interface
- Source can be 'truenas' or 'external'
- Aligns with middleware API schema changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add isExternalApp computed property to app-row and app-info-card
- Disable start/stop/restart buttons for external apps
- Disable edit/update/convert/rollback/delete buttons for external apps
- Show "Manage by deployment tool" tooltip for disabled buttons
- Roll Back button now hidden entirely for external apps

External apps (deployed via Portainer, Docker CLI, etc.) can only be
monitored in TrueNAS. Management operations must be performed through
the original deployment tool.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…d apps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…e usage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
… Apps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Hide Workloads, Notes, and Metadata tiles for external apps since
they are not managed by TrueNAS and these details are not relevant.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…f version info

For external apps in Application Info tile:
- Replace App Version and Version fields with "Deployment: Deployed via External Tool"
- Hide Source and Train fields (not relevant for external apps)
- Keep Name field visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Hide checkbox in app-row for external apps
- Filter external apps from checkedApps, activeCheckedApps, and stoppedCheckedApps
- Update allAppsChecked to only consider TrueNAS apps
- Update toggleAppsChecked to only select TrueNAS apps
- Prevents bulk actions (start/stop/update/delete) on external apps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@claude
Copy link

claude bot commented Nov 5, 2025

Claude finished @alexandrabain's task —— View job


Code Review Complete! 🎉

Great work on this feature addition! The implementation is solid overall with good test coverage and modern Angular patterns. Here's my feedback:


✅ Strengths

The PR demonstrates strong architectural decisions with signal-based reactive patterns, comprehensive test coverage (89.22%), and well-structured utility functions that handle edge cases properly.


🔴 Critical Issue: Memory Leak

src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts:617-619

There's a memory leak in the totalUtilization$ observable. The operator order is incorrect:

readonly totalUtilization$ = this.installedAppsStore.installedApps$.pipe(
  // ... transformations ...
  takeUntilDestroyed(this.destroyRef),
  shareReplay({ bufferSize: 1, refCount: true }),  // ❌ WRONG ORDER
);

Problem: When takeUntilDestroyed comes BEFORE shareReplay, template subscribers (| async) subscribe downstream of takeUntilDestroyed, so it cannot clean up the upstream sources. The combineLatest inside continues polling even after component destruction.

Solution: Place takeUntilDestroyed BEFORE shareReplay:

readonly totalUtilization$ = this.installedAppsStore.installedApps$.pipe(
  // ... transformations ...
  takeUntilDestroyed(this.destroyRef),           // ✅ First
  shareReplay({ bufferSize: 1, refCount: true }), // ✅ Second
);

Why this matters: When takeUntilDestroyed is BEFORE shareReplay, it properly completes the source stream on component destruction, stopping all upstream polling operations.

Note: I can see from your commit history (141c2d1) that you've already identified and attempted to fix this - make sure the correct order is in the final version! 🔍


⚠️ Other Issues

1. Missing null check in app-info-card effect

src/app/pages/apps/components/installed-apps/app-info-card/app-info-card.component.ts:95-99

The effect doesn't check if app() exists before calling updateRollbackSetup:

protected rollbackUpdateButtonSetEffect = effect(() => {
  const app = this.app();
  this.isRollbackPossible.set(false);
  this.updateRollbackSetup(app.name);  // ⚠️ What if app is undefined?
});

Suggestion: Add a null check or use allowSignalWrites: true if intentional:

protected rollbackUpdateButtonSetEffect = effect(() => {
  const app = this.app();
  if (!app) return;
  this.isRollbackPossible.set(false);
  this.updateRollbackSetup(app.name);
});

2. Potential navigation issue with external apps

src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts:523-533

The selectFirstApp() method assumes apps have metadata.train and id, but external apps might have different metadata structures:

private selectFirstApp(): void {
  const [firstApp] = this.sortedApps();
  if (firstApp.metadata.train && firstApp.id) {  // ⚠️ Safe for external apps?
    this.location.replaceState(['/apps', 'installed', firstApp.metadata.train, firstApp.id].join('/'));
  }
  // ...
}

Suggestion: Add explicit check for external apps or ensure external apps always have these properties.

3. Consider debouncing the selection change signal

src/app/pages/apps/components/installed-apps/installed-apps-list/installed-apps-list.component.ts:270-276

Every checkbox toggle triggers a signal update, which recomputes multiple computed signals. For bulk operations, this could cause performance issues:

toggleAppSelection(appId: string): void {
  this.selection.toggle(appId);
  this.selectionChanged.update((count) => count + 1); // Triggers all computed signals
}

Suggestion: Consider batching updates or using untracked() for bulk operations.


💡 Minor Suggestions

  1. Accessibility enhancement: The collapsible headers (installed-apps-list.component.html:131-146) have good ARIA attributes, but consider adding role="region" to the content sections for better screen reader navigation.

  2. i18n completeness: The new strings like "Apps managed by TrueNAS" and "External Apps" will need translation keys added. Make sure they're in the i18n files.

  3. Console.error in production: At line 605 of installed-apps-list.component.ts, there's a console.error in the catchError. Consider using a proper logging service instead for production environments.


📊 Test Coverage

Excellent test coverage! The new tests comprehensively cover:

  • External app filtering and separation ✅
  • Collapsible sections ✅
  • Total utilization calculations with edge cases ✅
  • Null/undefined handling ✅
  • Network stats aggregation ✅

🎯 Summary

The implementation is well-architected with proper separation of TrueNAS and external apps. Fix the memory leak operator order (critical), add the null check in the effect (important), and verify external app metadata handling. The rest is solid! 🚀


@codecov
Copy link

codecov bot commented Nov 5, 2025

Codecov Report

❌ Patch coverage is 89.22156% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.38%. Comparing base (b2f31fe) to head (141c2d1).
⚠️ Report is 5 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...stalled-apps-list/installed-apps-list.component.ts 88.28% 13 Missing ⚠️
...onents/installed-apps/app-row/app-row.component.ts 81.81% 2 Missing ⚠️
...ponents/installed-apps/installed-apps.component.ts 83.33% 2 Missing ⚠️
src/app/pages/apps/utils/network-stats.utils.ts 95.23% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #12775      +/-   ##
==========================================
+ Coverage   86.35%   86.38%   +0.02%     
==========================================
  Files        1832     1834       +2     
  Lines       68493    68574      +81     
  Branches     8392     8419      +27     
==========================================
+ Hits        59149    59236      +87     
+ Misses       9344     9338       -6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

alexandrabain and others added 13 commits November 6, 2025 10:26
- Add null safety checks (app.source ?? 'truenas') for backward compatibility
- Replace manual Observable with of() in totalUtilizationAnalysis getter
- Ensures external apps filtering works with existing apps that may not have source field

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add source field to mock app data
- Mock AppsStatsService.getStatsForApp() to return valid observable
- Prevents "undefined where stream was expected" error in combineLatest

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add optional chaining for app.source in isExternalApp computed
- Add source field to app-row test mock data
- Ensures backward compatibility with apps that don't have source field

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add source field to mock app data
- Mock AppsStatsService.getStatsForApp() to return valid observable
- Prevents combineLatest error in tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add null safety checks to incomingTrafficBits and outgoingTrafficBits computed signals
- Add keyboard navigation (Enter/Space) to collapsible section headers
- Add tabindex="0" for keyboard accessibility on section headers
- Prevents runtime errors when stats are null/undefined (e.g., stopped apps)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Fix stats aggregation performance: Convert getter to cached observable with shareReplay
- Add ARIA attributes (role, aria-expanded, aria-controls) for screen reader support
- Add helper methods (isExternalApp, isTruenasApp) for consistent app type checking
- Add defensive null checks to network stats (rx_bytes, tx_bytes)
- Improves maintainability and eliminates duplicate null-handling logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add external app (external-nginx) to test data
- Test filtering of TrueNAS vs external apps
- Test bulk selection excludes external apps
- Test active/stopped checked apps exclude external apps
- Update bulk update test to verify only TrueNAS apps included
- Improves test coverage for the main feature of this PR

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…ation

- Add optional chaining (stats?.cpu_usage, stats?.memory) for top-level properties
- Add optional chaining (net?.rx_bytes, net?.tx_bytes) for network stats
- Prevents potential runtime errors with null/undefined stats from external apps
- All stats properties now gracefully handle missing data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…leanup

- Replace @UntilDestroy() decorator with DestroyRef inject pattern
- Replace untilDestroyed(this) with takeUntilDestroyed(this.destroyRef)
- Update installed-apps-list.component.ts
- Update app-info-card.component.ts
- Follows modern Angular standards per CLAUDE.md guidelines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…ggregation

- Add safeAdd() helper to prevent NaN from null/undefined/non-number values
- Add safeNetworkSum() helper for safe network stats accumulation
- Improve empty check: !apps?.length || !apps.some((app) => !!app)
- Add typeof stats !== 'object' check to validate stats shape
- All math operations now guaranteed to return valid numbers, never NaN
- Handles all edge cases: null apps, undefined stats, missing properties, empty arrays

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Move $event.preventDefault() before signal update
- Ensures preventDefault() is called even if signal update throws error
- Prevents unwanted page scroll on space key press
- Applied to both TrueNAS Apps and Other Apps section headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…nal app detection utility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…stants

- Update App interface to use 'TRUENAS' | 'EXTERNAL' instead of lowercase
- Fix isExternalApp utility to check for 'EXTERNAL' instead of 'external'
- Update all component comparisons to use uppercase constants
- Update all test mocks and assertions to use uppercase values
- Ensures external app detection works correctly with middleware API
alexandrabain and others added 30 commits November 12, 2025 14:52
Move debounceTime(300) before switchMap to debounce app list changes
rather than the result stream. This prevents excessive recalculations
when apps are added/removed rapidly while avoiding the test failures
that occurred when debouncing was inside the switchMap.
Change stats from input.required<AppStats>() to input<AppStats | null>()
to accurately reflect that the async pipe can emit null before the
observable emits its first value. This matches the defensive coding
already in place with optional chaining.
Document that dataSource is sorted as a whole, but template filters
preserve sort order within separate TrueNAS and External sections.
This improves maintainability for future developers.
Converted 4 failing tests to use fakeAsync() and tick() to properly
handle timing with debounceTime(300) and shareReplay caching.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Changed mock from of(apps) to BehaviorSubject<App[]> to allow tests
to emit new values after setting up test-specific mocks. This properly
triggers the debounced observable chain and clears the shareReplay cache.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
1. Critical: Fixed memory leak in totalUtilization$ observable
   - Moved takeUntilDestroyed from inner combineLatest to outer chain
   - Prevents accumulation of subscriptions on each installedApps$ emission

2. Moderate: Prevent external apps from being selected
   - Added defensive check in toggleAppChecked() to block selection
   - Ensures selection model never contains external app IDs

3. Moderate: Improved JSDoc clarity for setDatasourceWithSort
   - Added sorting strategy explanation
   - Documented why apps are sorted together
   - Added concrete example for template rendering behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The takeUntilDestroyed operator is redundant because:
- shareReplay({ refCount: true }) automatically cleans up when no subscribers remain
- async pipe in template automatically unsubscribes on component destruction
- Having both could interfere with shareReplay's refCount behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…eplay

Correct operator placement for proper cleanup:
- takeUntilDestroyed BEFORE shareReplay ensures source subscription
  cleanup when component is destroyed
- shareReplay after it can still share among multiple template subscriptions
- refCount: true handles cleanup when all subscribers leave

Previous placement after shareReplay only affected downstream subscribers,
not the source subscription, risking accumulation on component recreation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The tests were failing because firstValueFrom was subscribing AFTER
the observable had already processed emissions with takeUntilDestroyed.

Fixed by:
- Subscribing directly before triggering installedApps$.next()
- Using synchronous subscription pattern in fakeAsync tests
- Properly unsubscribing after test assertions

This ensures the subscription is active when values are emitted
through the debounced observable chain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
1. Observable cleanup clarification
   - Removed takeUntilDestroyed from totalUtilization$ observable
   - Added comment explaining shareReplay({ refCount: true }) handles cleanup
   - Prevents future confusion about redundant cleanup operators

2. Performance: Convert getters to computed signals
   - Changed filteredTruenasApps and filteredExternalApps to computed()
   - Eliminates redundant filtering on every change detection cycle
   - Updated all usages in component, template, and tests

3. Data transformation consistency
   - Renamed networkRx/networkTx to networkRxBits/networkTxBits
   - Moved byte-to-bit conversion (* 8) into component logic
   - Removed multiplication from template for consistency with app-row
   - Updated test expectations (4500 bytes → 36000 bits)

4. Defensive programming documentation
   - Added comment to toggleAppChecked() explaining external app check
   - Clarifies this prevents selection if called programmatically

5. Test simplification
   - Converted 4 tests from manual subscription to await firstValueFrom()
   - Cleaner and more readable test patterns
   - Works correctly now that takeUntilDestroyed is removed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add accessibility attributes (role, aria-live) to utilization summary
- Consolidate network traffic calculations into memoized computed signal
- Fix safeAdd type signature to accept null/undefined for both parameters
- Create SCSS variable for grid columns to eliminate duplication
- Improve i18n keys for section headers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add takeUntilDestroyed before shareReplay to ensure proper cleanup when
component is destroyed. While shareReplay with refCount handles inner
subscriptions, takeUntilDestroyed ensures the entire chain is torn down
on component destruction, following CLAUDE.md guidelines.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Add missing 'computed' import from @angular/core
- Add explicit App type annotations to lambda parameters in:
  - isSelectedAppVisible getter
  - filteredTruenasApps computed
  - filteredExternalApps computed
  - toggleAppsChecked method

Fixes TS7006 and TS2304 errors preventing test compilation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…date

Add spectator.detectChanges() in beforeEach after setting dataSource
to ensure computed signals (filteredApps, filteredTruenasApps) are
re-evaluated and app rows render in the DOM.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Force filteredApps computed signal to recalculate by toggling
searchQuery signal. Since dataSource is a plain property (not a signal),
the computed only recalculates when its signal dependencies change.
Toggling searchQuery causes the computed to re-evaluate and read the
updated dataSource value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
- Refactored InstalledAppsListComponent to use modern signal-based architecture
  matching patterns in containers/enclosure/storage components
- Removed non-reactive dataSource property in favor of reactive signals
- Created sortedApps computed signal for proper reactive sorting
- Fixed test failures caused by non-reactive state management
- Extracted network stats calculation to shared utility (network-stats.utils.ts)
- Added error handling to totalUtilization$ observable with catchError
- Cleaned up redundant type guard check (isTruenasApp already performs null check)
- All tests passing (5/5), all lint rules pass
… architecture

Convert all remaining getters to computed signals for consistent reactivity:
- checkedApps, activeCheckedApps, stoppedCheckedApps
- allAppsChecked, hasCheckedApps, hasUpdates
- selectedApp converted to signal
- isSelectedAppVisible now reactive

Add selection change tracking:
- Implement selectionChanged signal to trigger reactive updates
- Add toggleAppSelection method to track selection state changes
- Ensure all selection-dependent UI updates automatically

Performance optimizations:
- Add appsById computed Map for O(1) app lookups
- Reduce checkedApps complexity from O(n*m) to O(n)
- Automatic memoization via computed signals

Extract network utilities:
- Export safeAdd helper function
- Add sumNetworkFieldAsBits utility for cleaner aggregations
- Remove duplicate safeAdd implementation

Accessibility improvements:
- Add aria-live="polite" to collapsible section headers
- Standardize terminology to "External Apps" throughout

Update tests:
- Modify tests to use signal-based API with () calls
- Use toggleAppSelection instead of direct selection manipulation
- Reset installedApps$ in beforeEach to prevent test pollution
- All 26 tests passing

This refactoring provides:
- Consistent reactive architecture throughout component
- Automatic change detection with OnPush strategy
- Better performance with large app lists
- Improved maintainability and predictable data flow
- Updated InstalledAppsComponent to use computed signals that properly call child component signals
- Added missing 'computed' import from @angular/core
- Updated template to call all signals with parentheses ()
- Fixed selectedApp, appsUpdateAvailable, hasUpdates, and isSelectedAppVisible to be computed signals
- Removed unused App interface import
- All tests passing (5/5 parent, 26/26 child)
- Lint passing
- Moved shareReplay before takeUntilDestroyed in totalUtilization$ observable
- This ensures proper cleanup when component is destroyed
- Previous order (takeUntilDestroyed before shareReplay) could cause the shared observable to not clean up properly
- Correct order: transformations → shareReplay → takeUntilDestroyed
- All tests passing (26/26)
- Lint passing
- Moved takeUntilDestroyed BEFORE shareReplay (not after)
- This ensures the source subscription is cleaned up when component is destroyed
- takeUntilDestroyed after shareReplay only affects subscribers, not the source
- Correct order: transformations → takeUntilDestroyed → shareReplay
- This corrects the previous commit which had the operators in wrong order
- All tests passing (26/26)
- Lint passing
…play

- Moved takeUntilDestroyed to the end of the pipe (after shareReplay)
- This ensures proper cleanup when component is destroyed
- takeUntilDestroyed must be last to unsubscribe from shareReplay
- shareReplay's refCount:true then triggers disconnection from source
- Correct order: transformations → shareReplay → takeUntilDestroyed
- All tests passing (26/26)
- Lint passing
- Moved takeUntilDestroyed to come BEFORE shareReplay (not after)
- When takeUntilDestroyed is AFTER shareReplay, template subscribers (| async)
  subscribe downstream of takeUntilDestroyed, so it can't clean up upstream sources
- When takeUntilDestroyed is BEFORE shareReplay, it properly completes the
  source stream when component is destroyed, stopping the combineLatest polling
- Correct order: transformations → takeUntilDestroyed → shareReplay
- This fixes the memory leak where app stats continued polling after component destruction
- All tests passing (26/26)
- Lint passing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants