1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-23 19:53:43 +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

@@ -1,15 +1,16 @@
<ng-container>
@let status = dataService.reportStatus$ | async;
@let hasCiphers = dataService.hasCiphers$ | async;
@let isGeneratingReport = dataService.isGeneratingReport$ | async;
@if (status == ReportStatusEnum.Initializing || hasCiphers === null) {
<!-- Show loading state when initializing risk insights -->
<dirt-risk-insights-loading></dirt-risk-insights-loading>
<!-- Show page loading state when initializing risk insights (quick page load) -->
<dirt-page-loading></dirt-page-loading>
} @else {
<!-- Check final states after initial calls have been completed -->
@if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) {
<h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1>
<!-- 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) {
<!-- Show Empty state when there are no applications (no ciphers to make reports on) -->
<empty-state-card
@@ -36,7 +37,7 @@
</div>
} @else {
<!-- 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>
<h1 bitTypography="h1">{{ "accessIntelligence" | i18n }}</h1>
<div class="tw-text-main tw-max-w-4xl tw-mb-2" *ngIf="appsCount > 0">
@@ -78,28 +79,33 @@
</div>
</div>
<div class="tw-flex-1 tw-flex tw-flex-col">
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
@if (isRiskInsightsActivityTabFeatureEnabled) {
<bit-tab label="{{ 'activity' | i18n }}">
<dirt-all-activity></dirt-all-activity>
@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">
<bit-tab-group [(selectedIndex)]="tabIndex" (selectedIndexChange)="onTabChange($event)">
@if (isRiskInsightsActivityTabFeatureEnabled) {
<bit-tab label="{{ 'activity' | i18n }}">
<dirt-all-activity></dirt-all-activity>
</bit-tab>
}
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
<dirt-all-applications></dirt-all-applications>
</bit-tab>
}
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: appsCount }}">
<dirt-all-applications></dirt-all-applications>
</bit-tab>
<bit-tab>
<ng-template bitTabLabel>
<i class="bwi bwi-star"></i>
{{
"criticalApplicationsWithCount"
| i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0
}}
</ng-template>
<dirt-critical-applications></dirt-critical-applications>
</bit-tab>
</bit-tab-group>
</div>
<bit-tab>
<ng-template bitTabLabel>
<i class="bwi bwi-star"></i>
{{
"criticalApplicationsWithCount"
| i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0
}}
</ng-template>
<dirt-critical-applications></dirt-critical-applications>
</bit-tab>
</bit-tab-group>
</div>
}
</div>
}
}

View File

@@ -1,3 +1,4 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core";
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 { EmptyStateCardComponent } from "./empty-state-card.component";
import { RiskInsightsTabType } from "./models/risk-insights.models";
import { PageLoadingComponent } from "./shared/page-loading.component";
import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -55,6 +57,15 @@ import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.com
DrawerHeaderComponent,
AllActivityComponent,
ApplicationsLoadingComponent,
PageLoadingComponent,
],
animations: [
trigger("fadeIn", [
transition(":enter", [
style({ opacity: 0 }),
animate("300ms 100ms ease-in", style({ opacity: 1 })),
]),
]),
],
})
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">
<i
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-primary-600"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
<p bitTypography="h2" class="tw-text-main tw-mb-0">
{{ "generatingYourAccessIntelligence" | i18n }}
</p>
<div class="tw-flex tw-justify-center tw-items-center tw-min-h-[60vh]">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<!-- Progress bar -->
<div class="tw-w-64" role="progressbar" attr.aria-label="{{ 'loadingProgress' | i18n }}">
<bit-progress
[barWidth]="progress()"
[showText]="false"
size="default"
bgColor="primary"
></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>

View File

@@ -1,15 +1,57 @@
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 {
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
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "dirt-risk-insights-loading",
imports: [CommonModule, JslibModule],
imports: [CommonModule, JslibModule, ProgressModule],
templateUrl: "./risk-insights-loading.component.html",
})
export class ApplicationsLoadingComponent {
constructor() {}
export class ApplicationsLoadingComponent implements OnInit {
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);
}
});
}
}