Throttle sidebar pending approvals badge refresh

This commit is contained in:
master
2026-03-11 15:51:22 +02:00
parent 9dd8592a2a
commit dc98d5a758
3 changed files with 143 additions and 7 deletions

View File

@@ -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 sprints 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.

View File

@@ -1,6 +1,9 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; 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 { AppSidebarComponent } from './app-sidebar.component';
import { APPROVAL_API, type ApprovalApi } from '../../core/api/approval.client';
import { import {
AUTH_SERVICE, AUTH_SERVICE,
MockAuthService, MockAuthService,
@@ -8,16 +11,32 @@ import {
StellaOpsScopes, StellaOpsScopes,
} from '../../core/auth'; } from '../../core/auth';
@Component({
standalone: true,
template: '',
})
class DummyRouteComponent {}
describe('AppSidebarComponent', () => { describe('AppSidebarComponent', () => {
let authService: MockAuthService; let authService: MockAuthService;
let approvalApi: jasmine.SpyObj<ApprovalApi>;
beforeEach(async () => { beforeEach(async () => {
authService = new MockAuthService(); authService = new MockAuthService();
approvalApi = jasmine.createSpyObj('ApprovalApi', ['listApprovals']) as jasmine.SpyObj<ApprovalApi>;
approvalApi.listApprovals.and.returnValue(of([]));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AppSidebarComponent], imports: [AppSidebarComponent],
providers: [ 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: AUTH_SERVICE, useValue: authService },
{ provide: APPROVAL_API, useValue: approvalApi },
], ],
}).compileComponents(); }).compileComponents();
}); });
@@ -168,6 +187,38 @@ describe('AppSidebarComponent', () => {
expect(hrefs).not.toContain('/setup/notifications'); 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 { function setScopes(scopes: readonly StellaOpsScope[]): void {
const baseUser = authService.user(); const baseUser = authService.user();
if (!baseUser) { if (!baseUser) {

View File

@@ -585,6 +585,8 @@ interface NavSectionGroup {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppSidebarComponent implements AfterViewInit { export class AppSidebarComponent implements AfterViewInit {
private static readonly PendingApprovalsBadgeRefreshWindowMs = 30_000;
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly authService = inject(AUTH_SERVICE) as AuthService; private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -618,6 +620,8 @@ export class AppSidebarComponent implements AfterViewInit {
private flyoutLeaveTimer: ReturnType<typeof setTimeout> | null = null; private flyoutLeaveTimer: ReturnType<typeof setTimeout> | null = null;
private readonly pendingApprovalsCount = signal(0); private readonly pendingApprovalsCount = signal(0);
private readonly pendingApprovalsBadgeLoadedAt = signal<number | null>(null);
private readonly pendingApprovalsBadgeLoading = signal(false);
/** /**
* Navigation sections - pre-alpha canonical IA. * Navigation sections - pre-alpha canonical IA.
@@ -837,13 +841,13 @@ export class AppSidebarComponent implements AfterViewInit {
}); });
constructor() { constructor() {
this.loadPendingApprovalsBadge(); this.loadPendingApprovalsBadge(true);
this.destroyRef.onDestroy(() => this.clearFlyoutTimers()); this.destroyRef.onDestroy(() => this.clearFlyoutTimers());
this.router.events this.router.events
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((event) => { .subscribe((event) => {
if (event instanceof NavigationEnd) { if (event instanceof NavigationEnd) {
this.loadPendingApprovalsBadge(); this.loadPendingApprovalsBadge(this.shouldForcePendingApprovalsRefresh(event.urlAfterRedirects));
this.doctorTrendService.refresh(); this.doctorTrendService.refresh();
this.closeFlyout(); this.closeFlyout();
} }
@@ -955,19 +959,49 @@ export class AppSidebarComponent implements AfterViewInit {
} }
} }
private loadPendingApprovalsBadge(): void { private loadPendingApprovalsBadge(force = false): void {
if (!this.approvalApi) { if (!this.approvalApi) {
return; 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( this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe({ ).subscribe({
next: (approvals) => this.pendingApprovalsCount.set(approvals.length), next: (approvals) => {
error: () => this.pendingApprovalsCount.set(0), 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. * Mouse-proximity auto-scroll for sidebar nav.
* When mouse is within 60px of top/bottom edges, scroll in that direction. * When mouse is within 60px of top/bottom edges, scroll in that direction.