Throttle sidebar pending approvals badge refresh
This commit is contained in:
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user