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 { 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<ApprovalApi>;
beforeEach(async () => {
authService = new MockAuthService();
approvalApi = jasmine.createSpyObj('ApprovalApi', ['listApprovals']) as jasmine.SpyObj<ApprovalApi>;
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) {

View File

@@ -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<typeof setTimeout> | null = null;
private readonly pendingApprovalsCount = signal(0);
private readonly pendingApprovalsBadgeLoadedAt = signal<number | null>(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.