From dc98d5a758cff1eee00103ae14e4ce6453cdfb96 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 11 Mar 2026 15:51:22 +0200 Subject: [PATCH] Throttle sidebar pending approvals badge refresh --- ...ending_approvals_badge_refresh_throttle.md | 51 +++++++++++++++++ .../app-sidebar/app-sidebar.component.spec.ts | 55 ++++++++++++++++++- .../app-sidebar/app-sidebar.component.ts | 44 +++++++++++++-- 3 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 docs/implplan/SPRINT_20260311_004_FE_sidebar_pending_approvals_badge_refresh_throttle.md diff --git a/docs/implplan/SPRINT_20260311_004_FE_sidebar_pending_approvals_badge_refresh_throttle.md b/docs/implplan/SPRINT_20260311_004_FE_sidebar_pending_approvals_badge_refresh_throttle.md new file mode 100644 index 000000000..c95762560 --- /dev/null +++ b/docs/implplan/SPRINT_20260311_004_FE_sidebar_pending_approvals_badge_refresh_throttle.md @@ -0,0 +1,51 @@ +# Sprint 20260311_004 - FE Sidebar Pending Approvals Badge Refresh Throttle + +## Topic & Scope +- Eliminate transient live route failures caused by the shared sidebar polling pending approvals on every navigation. +- Keep the approvals badge current where operators actually need freshness, without making unrelated pages fail under full-route Playwright sweeps. +- Preserve stale badge state on transient backend failures instead of zeroing the shell and creating false-negative QA noise. +- Working directory: `src/Web/StellaOps.Web/src/app/layout/app-sidebar`. +- Allowed coordination edits: `src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts`, `docs/implplan/SPRINT_20260311_004_FE_sidebar_pending_approvals_badge_refresh_throttle.md`. +- Expected evidence: focused Angular sidebar coverage, rebuilt web bundle synced into `compose_console-dist`, and a live Playwright canonical route sweep returning `111/111`. + +## Dependencies & Concurrency +- Depends on the live compose stack at `https://stella-ops.local`. +- Safe parallelism: stay inside the shared shell badge behavior; do not broaden this slice into approval queue data contracts or unrelated integrations screens. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md` + +## Delivery Tracker + +### FE-SIDEBAR-APPROVALS-001 - Stop shared-shell badge churn from poisoning unrelated route verification +Status: DONE +Dependency: none +Owners: QA, 3rd Line Support, Product Manager, Architect, Developer +Task description: +- A full authenticated Playwright canonical sweep regressed from `111/111` to `110/111`, but the reported failing page (`/ops/integrations/scm`) rendered correctly in isolation. Root-cause triage showed the shared sidebar was refetching pending approvals on every `NavigationEnd`, which turned the sweep into 100+ background calls to `/api/v2/releases/approvals?status=pending`. +- The clean fix is to treat the pending approvals badge as a stale-while-revalidate shell affordance: load it on startup, throttle ordinary navigation refreshes, force refresh when the operator enters approvals surfaces, and preserve the last known badge count on transient errors. + +Completion criteria: +- [x] The sidebar no longer refetches pending approvals on every unrelated navigation. +- [x] Approvals surfaces still force a badge refresh. +- [x] Transient approvals failures do not zero the badge after a successful load. +- [x] Focused sidebar tests pass. +- [x] The rebuilt live web returns `111/111` on `live-frontdoor-canonical-route-sweep.mjs`. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-11 | Sprint created after the live canonical route sweep dropped to `110/111` with `/ops/integrations/scm` falsely failing under a background `503` from `/api/v2/releases/approvals?status=pending`. Direct Playwright repro proved the SCM page itself was healthy and isolated the issue to shared-shell badge churn. | Developer | +| 2026-03-11 | Added stale-while-revalidate throttling to the pending approvals badge, forced refreshes only for approvals surfaces, and preserved stale badge state on transient failures. Added focused router-backed sidebar coverage for unrelated vs approvals navigation. | Developer | +| 2026-03-11 | `npx ng test --watch=false --progress=false --include=src/app/layout/app-sidebar/app-sidebar.component.spec.ts` passed `13/13`; `npm run build` passed; the rebuilt bundle was synced into `compose_console-dist`, `stellaops-router-gateway` was restarted, and `node ./scripts/live-frontdoor-canonical-route-sweep.mjs` returned `111/111` against `https://stella-ops.local`. | QA | + +## Decisions & Risks +- Decision: the pending approvals badge is a shell hint, not a hard-real-time signal. It should be refreshed aggressively only when the operator is on approvals surfaces. +- Decision: preserve the previous badge count after transient failures once a successful load has happened; zeroing the badge on a background hiccup misleads operators and creates false route failures. +- Risk: the current deployment sync still uses a direct copy into `compose_console-dist`; keep that operational detail outside this sprint’s code scope. + +## Next Checkpoints +- Commit the shared-shell badge repair. +- Start the next deep page-action sweep now that the canonical route matrix is back to green. diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts index b133e4b85..da9065c18 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts @@ -1,6 +1,9 @@ +import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { provideRouter, Router } from '@angular/router'; +import { of } from 'rxjs'; import { AppSidebarComponent } from './app-sidebar.component'; +import { APPROVAL_API, type ApprovalApi } from '../../core/api/approval.client'; import { AUTH_SERVICE, MockAuthService, @@ -8,16 +11,32 @@ import { StellaOpsScopes, } from '../../core/auth'; +@Component({ + standalone: true, + template: '', +}) +class DummyRouteComponent {} + describe('AppSidebarComponent', () => { let authService: MockAuthService; + let approvalApi: jasmine.SpyObj; beforeEach(async () => { authService = new MockAuthService(); + approvalApi = jasmine.createSpyObj('ApprovalApi', ['listApprovals']) as jasmine.SpyObj; + approvalApi.listApprovals.and.returnValue(of([])); + await TestBed.configureTestingModule({ imports: [AppSidebarComponent], providers: [ - provideRouter([]), + provideRouter([ + { path: '', component: DummyRouteComponent }, + { path: 'ops/integrations/scm', component: DummyRouteComponent }, + { path: 'ops/integrations/registries', component: DummyRouteComponent }, + { path: 'releases/approvals', component: DummyRouteComponent }, + ]), { provide: AUTH_SERVICE, useValue: authService }, + { provide: APPROVAL_API, useValue: approvalApi }, ], }).compileComponents(); }); @@ -168,6 +187,38 @@ describe('AppSidebarComponent', () => { expect(hrefs).not.toContain('/setup/notifications'); }); + it('does not refetch pending approvals badge on unrelated navigation within the refresh window', async () => { + setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ]); + const router = TestBed.inject(Router); + const fixture = createComponent(); + + expect(approvalApi.listApprovals.calls.count()).toBe(1); + + await router.navigateByUrl('/ops/integrations/scm'); + fixture.detectChanges(); + await fixture.whenStable(); + + await router.navigateByUrl('/ops/integrations/registries'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(approvalApi.listApprovals.calls.count()).toBe(1); + }); + + it('forces a pending approvals refresh when navigating into approvals surfaces', async () => { + setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ]); + const router = TestBed.inject(Router); + const fixture = createComponent(); + + expect(approvalApi.listApprovals.calls.count()).toBe(1); + + await router.navigateByUrl('/releases/approvals'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(approvalApi.listApprovals.calls.count()).toBe(2); + }); + function setScopes(scopes: readonly StellaOpsScope[]): void { const baseUser = authService.user(); if (!baseUser) { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 138f84ce4..200e2a556 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -585,6 +585,8 @@ interface NavSectionGroup { changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppSidebarComponent implements AfterViewInit { + private static readonly PendingApprovalsBadgeRefreshWindowMs = 30_000; + private readonly router = inject(Router); private readonly authService = inject(AUTH_SERVICE) as AuthService; private readonly destroyRef = inject(DestroyRef); @@ -618,6 +620,8 @@ export class AppSidebarComponent implements AfterViewInit { private flyoutLeaveTimer: ReturnType | null = null; private readonly pendingApprovalsCount = signal(0); + private readonly pendingApprovalsBadgeLoadedAt = signal(null); + private readonly pendingApprovalsBadgeLoading = signal(false); /** * Navigation sections - pre-alpha canonical IA. @@ -837,13 +841,13 @@ export class AppSidebarComponent implements AfterViewInit { }); constructor() { - this.loadPendingApprovalsBadge(); + this.loadPendingApprovalsBadge(true); this.destroyRef.onDestroy(() => this.clearFlyoutTimers()); this.router.events .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((event) => { if (event instanceof NavigationEnd) { - this.loadPendingApprovalsBadge(); + this.loadPendingApprovalsBadge(this.shouldForcePendingApprovalsRefresh(event.urlAfterRedirects)); this.doctorTrendService.refresh(); this.closeFlyout(); } @@ -955,19 +959,49 @@ export class AppSidebarComponent implements AfterViewInit { } } - private loadPendingApprovalsBadge(): void { + private loadPendingApprovalsBadge(force = false): void { if (!this.approvalApi) { return; } + const now = Date.now(); + const loadedAt = this.pendingApprovalsBadgeLoadedAt(); + if (!force) { + if (this.pendingApprovalsBadgeLoading()) { + return; + } + + if ( + loadedAt !== null + && now - loadedAt < AppSidebarComponent.PendingApprovalsBadgeRefreshWindowMs + ) { + return; + } + } + + this.pendingApprovalsBadgeLoading.set(true); this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe( takeUntilDestroyed(this.destroyRef) ).subscribe({ - next: (approvals) => this.pendingApprovalsCount.set(approvals.length), - error: () => this.pendingApprovalsCount.set(0), + next: (approvals) => { + this.pendingApprovalsCount.set(approvals.length); + this.pendingApprovalsBadgeLoadedAt.set(now); + this.pendingApprovalsBadgeLoading.set(false); + }, + error: () => { + if (loadedAt === null) { + this.pendingApprovalsCount.set(0); + } + this.pendingApprovalsBadgeLoading.set(false); + }, }); } + private shouldForcePendingApprovalsRefresh(url: string): boolean { + const path = (url || '').split('?')[0] ?? ''; + return path.startsWith('/releases/approvals') || path.startsWith('/releases/promotions'); + } + /** * Mouse-proximity auto-scroll for sidebar nav. * When mouse is within 60px of top/bottom edges, scroll in that direction.