1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

Add progress tracking to risk insights report generation (#17199)

* Add progress tracking to risk insights report generation

* added skeleton page loader
This commit is contained in:
Maximilian Power
2025-11-07 15:03:58 +01:00
committed by GitHub
parent 0debc17c1f
commit a0bba3957b
9 changed files with 302 additions and 48 deletions

View File

@@ -4446,6 +4446,30 @@
"generatingYourAccessIntelligence": { "generatingYourAccessIntelligence": {
"message": "Generating your Access Intelligence..." "message": "Generating your Access Intelligence..."
}, },
"fetchingMemberData": {
"message": "Fetching member data..."
},
"analyzingPasswordHealth": {
"message": "Analyzing password health..."
},
"calculatingRiskScores": {
"message": "Calculating risk scores..."
},
"generatingReportData": {
"message": "Generating report data..."
},
"savingReport": {
"message": "Saving report..."
},
"compilingInsights": {
"message": "Compiling insights..."
},
"loadingProgress": {
"message": "Loading progress"
},
"thisMightTakeFewMinutes": {
"message": "This might take a few minutes."
},
"riskInsightsRunReport": { "riskInsightsRunReport": {
"message": "Run report" "message": "Run report"
}, },

View File

@@ -107,6 +107,17 @@ export const ReportStatus = Object.freeze({
export type ReportStatus = (typeof ReportStatus)[keyof typeof ReportStatus]; export type ReportStatus = (typeof ReportStatus)[keyof typeof ReportStatus];
export const ReportProgress = Object.freeze({
FetchingMembers: 1,
AnalyzingPasswords: 2,
CalculatingRisks: 3,
GeneratingReport: 4,
Saving: 5,
Complete: 6,
} as const);
export type ReportProgress = (typeof ReportProgress)[keyof typeof ReportProgress];
export interface RiskInsightsData { export interface RiskInsightsData {
id: OrganizationReportId; id: OrganizationReportId;
creationDate: Date; creationDate: Date;

View File

@@ -56,6 +56,7 @@ import {
OrganizationReportSummary, OrganizationReportSummary,
ReportStatus, ReportStatus,
ReportState, ReportState,
ReportProgress,
ApplicationHealthReportDetail, ApplicationHealthReportDetail,
} from "../../models/report-models"; } from "../../models/report-models";
import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service"; import { MemberCipherDetailsApiService } from "../api/member-cipher-details-api.service";
@@ -128,6 +129,10 @@ export class RiskInsightsOrchestratorService {
private _generateReportTriggerSubject = new BehaviorSubject<boolean>(false); private _generateReportTriggerSubject = new BehaviorSubject<boolean>(false);
generatingReport$ = this._generateReportTriggerSubject.asObservable(); generatingReport$ = this._generateReportTriggerSubject.asObservable();
// Report generation progress
private _reportProgressSubject = new BehaviorSubject<ReportProgress | null>(null);
reportProgress$ = this._reportProgressSubject.asObservable();
// --------------------------- Critical Application data --------------------- // --------------------------- Critical Application data ---------------------
criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null); criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
@@ -631,19 +636,33 @@ export class RiskInsightsOrchestratorService {
organizationId: OrganizationId, organizationId: OrganizationId,
userId: UserId, userId: UserId,
): Observable<ReportState> { ): Observable<ReportState> {
// Generate the report // Reset progress at the start
this._reportProgressSubject.next(null);
this.logService.debug("[RiskInsightsOrchestratorService] Fetching member cipher details");
this._reportProgressSubject.next(ReportProgress.FetchingMembers);
// Generate the report - fetch member ciphers and org ciphers in parallel
const memberCiphers$ = from( const memberCiphers$ = from(
this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId),
).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers))); ).pipe(map((memberCiphers) => flattenMemberDetails(memberCiphers)));
return forkJoin([this._ciphers$.pipe(take(1)), memberCiphers$]).pipe( // Start the generation pipeline
tap(() => { const reportGeneration$ = forkJoin([this._ciphers$.pipe(take(1)), memberCiphers$]).pipe(
this.logService.debug("[RiskInsightsOrchestratorService] Generating new report"); switchMap(([ciphers, memberCiphers]) => {
this.logService.debug("[RiskInsightsOrchestratorService] Analyzing password health");
this._reportProgressSubject.next(ReportProgress.AnalyzingPasswords);
return this._getCipherHealth(ciphers ?? [], memberCiphers);
}),
map((cipherHealthReports) => {
this.logService.debug("[RiskInsightsOrchestratorService] Calculating risk scores");
this._reportProgressSubject.next(ReportProgress.CalculatingRisks);
return this.reportService.generateApplicationsReport(cipherHealthReports);
}),
tap(() => {
this.logService.debug("[RiskInsightsOrchestratorService] Generating report data");
this._reportProgressSubject.next(ReportProgress.GeneratingReport);
}), }),
switchMap(([ciphers, memberCiphers]) => this._getCipherHealth(ciphers ?? [], memberCiphers)),
map((cipherHealthReports) =>
this.reportService.generateApplicationsReport(cipherHealthReports),
),
withLatestFrom(this.rawReportData$), withLatestFrom(this.rawReportData$),
map(([report, previousReport]) => { map(([report, previousReport]) => {
// Update the application data // Update the application data
@@ -680,6 +699,8 @@ export class RiskInsightsOrchestratorService {
}; };
}), }),
switchMap(({ report, summary, applications, metrics }) => { switchMap(({ report, summary, applications, metrics }) => {
this.logService.debug("[RiskInsightsOrchestratorService] Saving report");
this._reportProgressSubject.next(ReportProgress.Saving);
return this.reportService return this.reportService
.saveRiskInsightsReport$(report, summary, applications, metrics, { .saveRiskInsightsReport$(report, summary, applications, metrics, {
organizationId, organizationId,
@@ -696,6 +717,10 @@ export class RiskInsightsOrchestratorService {
); );
}), }),
// Update the running state // Update the running state
tap(() => {
this.logService.debug("[RiskInsightsOrchestratorService] Report generation complete");
this._reportProgressSubject.next(ReportProgress.Complete);
}),
map((mappedResult): ReportState => { map((mappedResult): ReportState => {
const { id, report, summary, applications, contentEncryptionKey } = mappedResult; const { id, report, summary, applications, contentEncryptionKey } = mappedResult;
return { return {
@@ -723,7 +748,9 @@ export class RiskInsightsOrchestratorService {
error: null, error: null,
data: null, data: null,
}), }),
); ) as Observable<ReportState>;
return reportGeneration$;
} }
// Calculates the metrics for a report // Calculates the metrics for a report

View File

@@ -10,6 +10,7 @@ import {
DrawerType, DrawerType,
RiskInsightsEnrichedData, RiskInsightsEnrichedData,
ReportStatus, ReportStatus,
ReportProgress,
ApplicationHealthReportDetail, ApplicationHealthReportDetail,
OrganizationReportApplication, OrganizationReportApplication,
} from "../../models"; } from "../../models";
@@ -38,6 +39,7 @@ export class RiskInsightsDataService {
readonly isGeneratingReport$: Observable<boolean> = of(false); readonly isGeneratingReport$: Observable<boolean> = of(false);
readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null); readonly criticalReportResults$: Observable<RiskInsightsEnrichedData | null> = of(null);
readonly hasCiphers$: Observable<boolean | null> = of(null); readonly hasCiphers$: Observable<boolean | null> = of(null);
readonly reportProgress$: Observable<ReportProgress | null> = of(null);
// New applications that need review (reviewedDate === null) // New applications that need review (reviewedDate === null)
readonly newApplications$: Observable<ApplicationHealthReportDetail[]> = of([]); readonly newApplications$: Observable<ApplicationHealthReportDetail[]> = of([]);
@@ -62,6 +64,7 @@ export class RiskInsightsDataService {
this.enrichedReportData$ = this.orchestrator.enrichedReportData$; this.enrichedReportData$ = this.orchestrator.enrichedReportData$;
this.criticalReportResults$ = this.orchestrator.criticalReportResults$; this.criticalReportResults$ = this.orchestrator.criticalReportResults$;
this.newApplications$ = this.orchestrator.newApplications$; this.newApplications$ = this.orchestrator.newApplications$;
this.reportProgress$ = this.orchestrator.reportProgress$;
this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged()); this.hasCiphers$ = this.orchestrator.hasCiphers$.pipe(distinctUntilChanged());

View File

@@ -1,15 +1,16 @@
<ng-container> <ng-container>
@let status = dataService.reportStatus$ | async; @let status = dataService.reportStatus$ | async;
@let hasCiphers = dataService.hasCiphers$ | async; @let hasCiphers = dataService.hasCiphers$ | async;
@let isGeneratingReport = dataService.isGeneratingReport$ | async;
@if (status == ReportStatusEnum.Initializing || hasCiphers === null) { @if (status == ReportStatusEnum.Initializing || hasCiphers === null) {
<!-- Show loading state when initializing risk insights --> <!-- Show page loading state when initializing risk insights (quick page load) -->
<dirt-risk-insights-loading></dirt-risk-insights-loading> <dirt-page-loading></dirt-page-loading>
} @else { } @else {
<!-- Check final states after initial calls have been completed --> <!-- Check final states after initial calls have been completed -->
@if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) { @if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) {
<h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1> <h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1>
<!-- Show empty state only when feature flag is enabled and there's no report data --> <!-- Show empty state only when feature flag is enabled and there's no report data -->
<div class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full"> <div @fadeIn class="tw-flex tw-justify-center tw-items-center tw-min-h-[70vh] tw-w-full">
@if (!hasCiphers) { @if (!hasCiphers) {
<!-- Show Empty state when there are no applications (no ciphers to make reports on) --> <!-- Show Empty state when there are no applications (no ciphers to make reports on) -->
<empty-state-card <empty-state-card
@@ -36,7 +37,7 @@
</div> </div>
} @else { } @else {
<!-- Show screen when there is report data OR when feature flag is disabled (show tabs even without data) --> <!-- Show screen when there is report data OR when feature flag is disabled (show tabs even without data) -->
<div class="tw-min-h-screen tw-flex tw-flex-col"> <div @fadeIn class="tw-min-h-screen tw-flex tw-flex-col">
<div> <div>
<h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1> <h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1>
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0"> <div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0">
@@ -78,6 +79,10 @@
</div> </div>
</div> </div>
@if (status == ReportStatusEnum.Loading && isGeneratingReport) {
<!-- Show detailed progress when generating report (longer operation) -->
<dirt-risk-insights-loading></dirt-risk-insights-loading>
} @else {
<div class="tw-flex-1 tw-flex tw-flex-col"> <div class="tw-flex-1 tw-flex tw-flex-col">
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)"> <bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
@if (isRiskInsightsActivityTabFeatureEnabled) { @if (isRiskInsightsActivityTabFeatureEnabled) {
@@ -100,6 +105,7 @@
</bit-tab> </bit-tab>
</bit-tab-group> </bit-tab-group>
</div> </div>
}
</div> </div>
} }
} }

View File

@@ -1,3 +1,4 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -34,6 +35,7 @@ import { AllApplicationsComponent } from "./all-applications/all-applications.co
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
import { EmptyStateCardComponent } from "./empty-state-card.component"; import { EmptyStateCardComponent } from "./empty-state-card.component";
import { RiskInsightsTabType } from "./models/risk-insights.models"; import { RiskInsightsTabType } from "./models/risk-insights.models";
import { PageLoadingComponent } from "./shared/page-loading.component";
import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component"; import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -55,6 +57,15 @@ import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.com
DrawerHeaderComponent, DrawerHeaderComponent,
AllActivityComponent, AllActivityComponent,
ApplicationsLoadingComponent, ApplicationsLoadingComponent,
PageLoadingComponent,
],
animations: [
trigger("fadeIn", [
transition(":enter", [
style({ opacity: 0 }),
animate("300ms 100ms ease-in", style({ opacity: 1 })),
]),
]),
], ],
}) })
export class RiskInsightsComponent implements OnInit, OnDestroy { export class RiskInsightsComponent implements OnInit, OnDestroy {

View File

@@ -0,0 +1,118 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
CardComponent as BitCardComponent,
SkeletonComponent,
SkeletonGroupComponent,
SkeletonTextComponent,
} from "@bitwarden/components";
// Page loading component for quick initial loads
// Uses skeleton animations to match the full page layout including header, tabs, and widget cards
// Includes smooth fade-out transition when loading completes
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "dirt-page-loading",
imports: [
JslibModule,
BitCardComponent,
SkeletonComponent,
SkeletonGroupComponent,
SkeletonTextComponent,
],
animations: [
trigger("fadeOut", [transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))])]),
],
template: `
<div class="tw-sr-only" role="status">{{ "loading" | i18n }}</div>
<div @fadeOut class="tw-min-h-screen tw-flex tw-flex-col">
<!-- Header Section -->
<header>
<!-- Page Title -->
<bit-skeleton edgeShape="box" class="tw-h-10 tw-w-48 tw-mb-2"></bit-skeleton>
<!-- Description -->
<bit-skeleton edgeShape="box" class="tw-h-5 tw-w-96 tw-mb-2"></bit-skeleton>
<!-- Info Banner -->
<div
class="tw-bg-primary-100 tw-rounded-lg tw-w-full tw-px-8 tw-py-4 tw-my-4 tw-flex tw-items-center tw-gap-4"
>
<bit-skeleton edgeShape="box" class="tw-size-5"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-4 tw-flex-1 tw-max-w-md"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-8 tw-w-32"></bit-skeleton>
</div>
</header>
<!-- Tabs Section -->
<div class="tw-flex-1 tw-flex tw-flex-col">
<!-- Tab Headers -->
<div class="tw-flex tw-gap-6 tw-mb-6">
<bit-skeleton edgeShape="box" class="tw-h-10 tw-w-24"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-10 tw-w-32"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-10 tw-w-40"></bit-skeleton>
</div>
<!-- Tab Content: Activity Cards Grid -->
<ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none"
>
<!-- Password Change Metric skeleton -->
<li class="tw-col-span-1">
<bit-card class="tw-h-56">
<div class="tw-flex tw-flex-col">
<bit-skeleton edgeShape="box" class="tw-h-6 tw-w-48 tw-mb-2"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-9 tw-w-full tw-mb-2"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-3/4"></bit-skeleton-text>
</div>
</bit-card>
</li>
<!-- Activity Card 1: At Risk Members -->
<li class="tw-col-span-1">
<bit-card class="tw-h-56">
<div class="tw-flex tw-flex-col">
<bit-skeleton edgeShape="box" class="tw-h-6 tw-w-32 tw-mb-4"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-9 tw-w-24 tw-mb-4"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-full tw-mb-4"></bit-skeleton-text>
<bit-skeleton edgeShape="box" class="tw-h-6 tw-w-40"></bit-skeleton>
</div>
</bit-card>
</li>
<!-- Activity Card 2: Critical Applications -->
<li class="tw-col-span-1">
<bit-card class="tw-h-56">
<div class="tw-flex tw-flex-col">
<bit-skeleton edgeShape="box" class="tw-h-6 tw-w-36 tw-mb-4"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-9 tw-w-32 tw-mb-4"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-full tw-mb-4"></bit-skeleton-text>
<bit-skeleton edgeShape="box" class="tw-h-6 tw-w-44"></bit-skeleton>
</div>
</bit-card>
</li>
<!-- Activity Card 3: Applications Needing Review -->
<li class="tw-col-span-1">
<bit-card class="tw-h-56">
<div class="tw-flex tw-flex-col">
<bit-skeleton edgeShape="box" class="tw-h-6 tw-w-44 tw-mb-4"></bit-skeleton>
<bit-skeleton-group class="tw-mb-4">
<bit-skeleton edgeShape="circle" class="tw-size-5" slot="start"></bit-skeleton>
<bit-skeleton edgeShape="box" class="tw-h-9 tw-w-28"></bit-skeleton>
</bit-skeleton-group>
<bit-skeleton-text [lines]="2" class="tw-w-full tw-mb-4"></bit-skeleton-text>
<bit-skeleton edgeShape="box" class="tw-h-8 tw-w-28"></bit-skeleton>
</div>
</bit-card>
</li>
</ul>
</div>
</div>
`,
})
export class PageLoadingComponent {}

View File

@@ -1,11 +1,23 @@
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center tw-min-h-[70vh] tw-gap-4"> <div class="tw-flex tw-justify-center tw-items-center tw-min-h-[60vh]">
<i <div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-primary-600" <!-- Progress bar -->
title="{{ 'loading' | i18n }}" <div class="tw-w-64" role="progressbar" attr.aria-label="{{ 'loadingProgress' | i18n }}">
aria-hidden="true" <bit-progress
></i> [barWidth]="progress()"
<span class="tw-sr-only">{{ "loading" | i18n }}</span> [showText]="false"
<p bitTypography="h2" class="tw-text-main tw-mb-0"> size="default"
{{ "generatingYourAccessIntelligence" | i18n }} bgColor="primary"
</p> ></bit-progress>
</div>
<!-- Status message and subtitle -->
<div class="tw-text-center tw-flex tw-flex-col tw-gap-1">
<span class="tw-text-main tw-text-base tw-font-medium tw-leading-4">
{{ currentMessage() | i18n }}
</span>
<span class="tw-text-muted tw-text-sm tw-font-normal tw-leading-4">
{{ "thisMightTakeFewMinutes" | i18n }}
</span>
</div>
</div>
</div> </div>

View File

@@ -1,15 +1,57 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core"; import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
ReportProgress,
RiskInsightsDataService,
} from "@bitwarden/bit-common/dirt/reports/risk-insights";
import { ProgressModule } from "@bitwarden/components";
const PROGRESS_STEPS = [
{ step: ReportProgress.FetchingMembers, message: "fetchingMemberData", progress: 20 },
{ step: ReportProgress.AnalyzingPasswords, message: "analyzingPasswordHealth", progress: 40 },
{ step: ReportProgress.CalculatingRisks, message: "calculatingRiskScores", progress: 60 },
{ step: ReportProgress.GeneratingReport, message: "generatingReportData", progress: 80 },
{ step: ReportProgress.Saving, message: "savingReport", progress: 95 },
{ step: ReportProgress.Complete, message: "compilingInsights", progress: 100 },
] as const;
type LoadingMessage = (typeof PROGRESS_STEPS)[number]["message"];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
selector: "dirt-risk-insights-loading", selector: "dirt-risk-insights-loading",
imports: [CommonModule, JslibModule], imports: [CommonModule, JslibModule, ProgressModule],
templateUrl: "./risk-insights-loading.component.html", templateUrl: "./risk-insights-loading.component.html",
}) })
export class ApplicationsLoadingComponent { export class ApplicationsLoadingComponent implements OnInit {
constructor() {} private dataService = inject(RiskInsightsDataService);
private destroyRef = inject(DestroyRef);
readonly currentMessage = signal<LoadingMessage>(PROGRESS_STEPS[0].message);
readonly progress = signal<number>(PROGRESS_STEPS[0].progress);
ngOnInit(): void {
// Subscribe to actual progress events from the orchestrator
this.dataService.reportProgress$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((progressStep) => {
if (progressStep === null) {
// Reset to initial state
this.currentMessage.set(PROGRESS_STEPS[0].message);
this.progress.set(PROGRESS_STEPS[0].progress);
return;
}
// Find the matching step configuration
const stepConfig = PROGRESS_STEPS.find((config) => config.step === progressStep);
if (stepConfig) {
this.currentMessage.set(stepConfig.message);
this.progress.set(stepConfig.progress);
}
});
}
} }