mirror of
https://github.com/bitwarden/browser
synced 2026-02-02 09:43:29 +00:00
added risk over time chart with mock data
This commit is contained in:
@@ -20,6 +20,45 @@
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"riskOverTime": {
|
||||
"message": "Risk over time"
|
||||
},
|
||||
"applicationsAtRiskOverTime": {
|
||||
"message": "Applications at-risk over time"
|
||||
},
|
||||
"itemsAtRiskOverTime": {
|
||||
"message": "Items at-risk over time"
|
||||
},
|
||||
"membersAtRiskOverTime": {
|
||||
"message": "Members at-risk over time"
|
||||
},
|
||||
"currentPeriod": {
|
||||
"message": "Current year"
|
||||
},
|
||||
"previousPeriod": {
|
||||
"message": "Previous year"
|
||||
},
|
||||
"last3Months": {
|
||||
"message": "Last 3 months"
|
||||
},
|
||||
"last6Months": {
|
||||
"message": "Last 6 months"
|
||||
},
|
||||
"last12Months": {
|
||||
"message": "Last 12 months"
|
||||
},
|
||||
"loadingRiskData": {
|
||||
"message": "Loading risk data..."
|
||||
},
|
||||
"downloadChart": {
|
||||
"message": "Download chart as SVG"
|
||||
},
|
||||
"downloadAsSVG": {
|
||||
"message": "Download as SVG"
|
||||
},
|
||||
"errorLoadingData": {
|
||||
"message": "Error loading data"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
},
|
||||
|
||||
@@ -1,67 +1,79 @@
|
||||
@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) {
|
||||
<dirt-risk-insights-loading></dirt-risk-insights-loading>
|
||||
} @else {
|
||||
<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"
|
||||
>
|
||||
<li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': passwordChangeMetricHasProgressBar }">
|
||||
<dirt-password-change-metric></dirt-password-change-metric>
|
||||
</li>
|
||||
<div class="tw-flex tw-flex-col tw-gap-6">
|
||||
<!-- First Row: Risk Over Time Chart (left) and Applications Needing Review (right) -->
|
||||
<div class="tw-grid tw-grid-cols-2 tw-gap-6">
|
||||
<!-- Risk Over Time Chart - Half Width -->
|
||||
<div class="tw-col-span-1">
|
||||
<dirt-risk-over-time></dirt-risk-over-time>
|
||||
</div>
|
||||
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
[title]="'atRiskMembers' | i18n"
|
||||
[cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount"
|
||||
[metricDescription]="'membersWithAccessToAtRiskItemsForCriticalApps' | i18n"
|
||||
actionText="{{ 'viewAtRiskMembers' | i18n }}"
|
||||
[showActionLink]="totalCriticalAppsAtRiskMemberCount > 0"
|
||||
(actionClick)="onViewAtRiskMembers()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
<!-- Applications Needing Review Card - Half Width -->
|
||||
<div class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
[title]="'applicationsNeedingReview' | i18n"
|
||||
[cardMetrics]="
|
||||
isAllCaughtUp
|
||||
? ('allCaughtUp' | i18n)
|
||||
: ('newApplicationsWithCount' | i18n: newApplicationsCount)
|
||||
"
|
||||
[metricDescription]="
|
||||
isAllCaughtUp
|
||||
? ('noNewApplicationsToReviewAtThisTime' | i18n)
|
||||
: ('newApplicationsDescription' | i18n)
|
||||
"
|
||||
[iconClass]="isAllCaughtUp ? 'bwi-check-circle' : 'bwi-exclamation-triangle'"
|
||||
[iconColorClass]="isAllCaughtUp ? 'tw-text-success' : 'tw-text-muted'"
|
||||
[buttonText]="isAllCaughtUp ? '' : ('reviewNow' | i18n)"
|
||||
[buttonType]="'primary'"
|
||||
(buttonClick)="onReviewNewApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
#allAppsOrgAtRiskApplications
|
||||
[title]="'criticalApplications' | i18n"
|
||||
[cardMetrics]="
|
||||
totalCriticalAppsCount === 0
|
||||
? ('countOfCriticalApplications' | i18n: totalCriticalAppsCount)
|
||||
: ('countOfApplicationsAtRisk' | i18n: totalCriticalAppsAtRiskCount)
|
||||
"
|
||||
[metricDescription]="
|
||||
totalCriticalAppsCount === 0
|
||||
? ('onceYouMarkApplicationsCriticalTheyWillDisplayHere' | i18n)
|
||||
: ('criticalApplicationsAreAtRisk'
|
||||
| i18n: totalCriticalAppsAtRiskCount : totalCriticalAppsCount)
|
||||
"
|
||||
actionText="{{ 'viewAtRiskApplications' | i18n }}"
|
||||
[showActionLink]="totalCriticalAppsAtRiskCount > 0"
|
||||
(actionClick)="onViewAtRiskApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
<!-- Second Row: Activity Cards Section -->
|
||||
<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"
|
||||
>
|
||||
<li class="tw-col-span-1" [ngClass]="{ 'tw-col-span-2': passwordChangeMetricHasProgressBar }">
|
||||
<dirt-password-change-metric></dirt-password-change-metric>
|
||||
</li>
|
||||
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
[title]="'applicationsNeedingReview' | i18n"
|
||||
[cardMetrics]="
|
||||
isAllCaughtUp
|
||||
? ('allCaughtUp' | i18n)
|
||||
: ('newApplicationsWithCount' | i18n: newApplicationsCount)
|
||||
"
|
||||
[metricDescription]="
|
||||
isAllCaughtUp
|
||||
? ('noNewApplicationsToReviewAtThisTime' | i18n)
|
||||
: ('newApplicationsDescription' | i18n)
|
||||
"
|
||||
[iconClass]="isAllCaughtUp ? 'bwi-check-circle' : 'bwi-exclamation-triangle'"
|
||||
[iconColorClass]="isAllCaughtUp ? 'tw-text-success' : 'tw-text-muted'"
|
||||
[buttonText]="isAllCaughtUp ? '' : ('reviewNow' | i18n)"
|
||||
[buttonType]="'primary'"
|
||||
(buttonClick)="onReviewNewApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
</ul>
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
[title]="'atRiskMembers' | i18n"
|
||||
[cardMetrics]="'membersAtRiskCount' | i18n: totalCriticalAppsAtRiskMemberCount"
|
||||
[metricDescription]="'membersWithAccessToAtRiskItemsForCriticalApps' | i18n"
|
||||
actionText="{{ 'viewAtRiskMembers' | i18n }}"
|
||||
[showActionLink]="totalCriticalAppsAtRiskMemberCount > 0"
|
||||
(actionClick)="onViewAtRiskMembers()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
#allAppsOrgAtRiskApplications
|
||||
[title]="'criticalApplications' | i18n"
|
||||
[cardMetrics]="
|
||||
totalCriticalAppsCount === 0
|
||||
? ('countOfCriticalApplications' | i18n: totalCriticalAppsCount)
|
||||
: ('countOfApplicationsAtRisk' | i18n: totalCriticalAppsAtRiskCount)
|
||||
"
|
||||
[metricDescription]="
|
||||
totalCriticalAppsCount === 0
|
||||
? ('onceYouMarkApplicationsCriticalTheyWillDisplayHere' | i18n)
|
||||
: ('criticalApplicationsAreAtRisk'
|
||||
| i18n: totalCriticalAppsAtRiskCount : totalCriticalAppsCount)
|
||||
"
|
||||
actionText="{{ 'viewAtRiskApplications' | i18n }}"
|
||||
[showActionLink]="totalCriticalAppsAtRiskCount > 0"
|
||||
(actionClick)="onViewAtRiskApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { RiskOverTimeComponent } from "../risk-over-time/risk-over-time.component";
|
||||
import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.component";
|
||||
|
||||
import { ActivityCardComponent } from "./activity-card.component";
|
||||
@@ -31,6 +32,7 @@ import { NewApplicationsDialogComponent } from "./new-applications-dialog.compon
|
||||
SharedModule,
|
||||
ActivityCardComponent,
|
||||
PasswordChangeMetricComponent,
|
||||
RiskOverTimeComponent,
|
||||
],
|
||||
templateUrl: "./all-activity.component.html",
|
||||
})
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { RiskMetricType, TimePeriod, RiskOverTimeData } from "./risk-over-time.models";
|
||||
|
||||
/**
|
||||
* Service for managing risk over time data
|
||||
* This service provides data for the risk over time chart component
|
||||
*/
|
||||
@Injectable()
|
||||
export class RiskOverTimeDataService {
|
||||
/**
|
||||
* Get risk over time data for a specific metric and time period
|
||||
* TODO: Replace with actual API call once backend endpoint is ready
|
||||
*/
|
||||
getRiskOverTimeData(metric: RiskMetricType, period: TimePeriod): Observable<RiskOverTimeData> {
|
||||
// Mock data for demonstration
|
||||
// In production, this should call an API endpoint
|
||||
return of(this.getMockData(metric, period));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate mock data for development/testing
|
||||
* This should be replaced with actual API calls once the backend is ready
|
||||
*/
|
||||
private getMockData(metric: RiskMetricType, period: TimePeriod): RiskOverTimeData {
|
||||
const data: RiskOverTimeData = {
|
||||
labels: [],
|
||||
currentPeriod: [],
|
||||
previousPeriod: [],
|
||||
metricType: metric,
|
||||
timePeriod: period,
|
||||
};
|
||||
|
||||
switch (period) {
|
||||
case TimePeriod.ThreeMonths:
|
||||
data.labels = ["Sep", "Oct", "Nov"];
|
||||
break;
|
||||
case TimePeriod.SixMonths:
|
||||
data.labels = ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
break;
|
||||
case TimePeriod.TwelveMonths:
|
||||
data.labels = ["Jan", "Mar", "May", "Jul", "Sep", "Nov"];
|
||||
break;
|
||||
}
|
||||
|
||||
// Generate mock data points based on metric type
|
||||
const dataPointCount = data.labels.length;
|
||||
|
||||
switch (metric) {
|
||||
case RiskMetricType.Applications:
|
||||
// Mock data showing improvement (downward trend)
|
||||
data.currentPeriod = this.generateTrendData(dataPointCount, 45, 30, true);
|
||||
data.previousPeriod = this.generateTrendData(dataPointCount, 50, 35, true);
|
||||
break;
|
||||
|
||||
case RiskMetricType.Items:
|
||||
// Mock data showing worsening (upward trend)
|
||||
data.currentPeriod = this.generateTrendData(dataPointCount, 30, 50, false);
|
||||
data.previousPeriod = this.generateTrendData(dataPointCount, 25, 40, false);
|
||||
break;
|
||||
|
||||
case RiskMetricType.Members:
|
||||
// Mock data showing slight improvement
|
||||
data.currentPeriod = this.generateTrendData(dataPointCount, 60, 50, true);
|
||||
data.previousPeriod = this.generateTrendData(dataPointCount, 65, 55, true);
|
||||
break;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trend data with some variation
|
||||
* @param count Number of data points
|
||||
* @param start Starting value
|
||||
* @param end Ending value
|
||||
* @param improving Whether trend is improving (downward) or worsening (upward)
|
||||
*/
|
||||
private generateTrendData(
|
||||
count: number,
|
||||
start: number,
|
||||
end: number,
|
||||
improving: boolean,
|
||||
): number[] {
|
||||
const data: number[] = [];
|
||||
const step = (end - start) / (count - 1);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Add some random variation to make it look more realistic
|
||||
const baseValue = start + step * i;
|
||||
const variation = (Math.random() - 0.5) * 5; // +/- 2.5
|
||||
data.push(Math.max(0, Math.round(baseValue + variation)));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate if a trend is improving based on first and last values
|
||||
*/
|
||||
isImproving(data: number[]): boolean {
|
||||
if (data.length < 2) {
|
||||
return true;
|
||||
}
|
||||
return data[data.length - 1] < data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the percentage change between two values
|
||||
*/
|
||||
getPercentageChange(oldValue: number, newValue: number): number {
|
||||
if (oldValue === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(((newValue - oldValue) / oldValue) * 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<div
|
||||
class="tw-box-border tw-bg-background tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-rounded-xl tw-p-6 tw-flex tw-flex-col tw-gap-6 tw-h-full"
|
||||
>
|
||||
<!-- Header Section -->
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<!-- Title and Download Button Row -->
|
||||
<div class="tw-flex tw-justify-between tw-items-start">
|
||||
<div class="tw-text-main tw-text-sm tw-font-semibold">{{ "riskOverTime" | i18n }}</div>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
size="small"
|
||||
label="{{ 'downloadChart' | i18n }}"
|
||||
(click)="downloadChartAsSVG()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<!-- Chart Title and Period Selector -->
|
||||
<div class="tw-flex tw-justify-between tw-items-end">
|
||||
<div class="tw-text-main tw-text-2xl tw-font-semibold">{{ chartTitle() }}</div>
|
||||
|
||||
<!-- Period Dropdown -->
|
||||
<div class="tw-relative">
|
||||
<select
|
||||
class="tw-px-3 tw-py-1 tw-rounded-full tw-border tw-border-gray-400 dark:tw-border-gray-600 tw-flex tw-items-center tw-gap-2 hover:tw-bg-gray-50 dark:hover:tw-bg-gray-700 tw-bg-white dark:tw-bg-gray-800 tw-text-gray-600 dark:tw-text-gray-400 tw-text-sm tw-leading-tight tw-appearance-none tw-pr-8 tw-cursor-pointer focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-primary-600"
|
||||
[value]="selectedPeriod"
|
||||
(change)="onPeriodChange($event)"
|
||||
>
|
||||
<option *ngFor="let option of periodOptions" [value]="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="tw-absolute tw-right-2 tw-top-1/2 tw-transform tw--translate-y-1/2 tw-w-3.5 tw-h-3.5 tw-text-gray-600 dark:tw-text-gray-400 tw-pointer-events-none"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Buttons and Legend -->
|
||||
<div class="tw-flex tw-justify-between tw-items-center">
|
||||
<!-- Toggle Group -->
|
||||
<bit-toggle-group [(selected)]="selectedMetric" (selectedChange)="onMetricChange($event)">
|
||||
<bit-toggle [value]="RiskMetricType.Applications">
|
||||
{{ "application" | i18n }}
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="RiskMetricType.Items">
|
||||
{{ "item" | i18n }}
|
||||
</bit-toggle>
|
||||
|
||||
<bit-toggle [value]="RiskMetricType.Members">
|
||||
{{ "member" | i18n }}
|
||||
</bit-toggle>
|
||||
</bit-toggle-group>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="tw-flex tw-gap-3 tw-items-center">
|
||||
<div class="tw-flex tw-items-center tw-gap-1.5 tw-pl-1 tw-pr-2 tw-py-0.5 tw-rounded-lg">
|
||||
<div
|
||||
class="tw-w-2.5 tw-h-2.5 tw-rounded-full"
|
||||
[ngClass]="currentPeriodColor()"
|
||||
[style.background-color]="currentPeriodStrokeColor()"
|
||||
></div>
|
||||
<span class="tw-text-main tw-text-xs">{{ "currentPeriod" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-1.5 tw-pl-1 tw-pr-2 tw-py-0.5 tw-rounded-lg">
|
||||
<div class="tw-w-2.5 tw-h-2.5 tw-rounded-full tw-bg-secondary-300"></div>
|
||||
<span class="tw-text-main tw-text-xs">{{ "previousPeriod" | i18n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="tw-relative tw-w-full" *ngIf="chartData() && !isLoading() && !hasError()">
|
||||
<svg
|
||||
#chartSvg
|
||||
[attr.width]="chartWidth"
|
||||
[attr.height]="chartHeight"
|
||||
class="tw-max-w-full"
|
||||
(mousemove)="onChartMouseMove($event)"
|
||||
(mouseleave)="onChartMouseLeave()"
|
||||
>
|
||||
<defs>
|
||||
<!-- Gradient for current period area -->
|
||||
<linearGradient id="currentGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" [attr.stop-color]="currentPeriodStrokeColor()" stop-opacity="0.08" />
|
||||
<stop offset="40%" [attr.stop-color]="currentPeriodStrokeColor()" stop-opacity="0.02" />
|
||||
<stop offset="100%" [attr.stop-color]="currentPeriodStrokeColor()" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Y-axis line -->
|
||||
<line
|
||||
[attr.x1]="yAxisX"
|
||||
[attr.y1]="chartPadding.top"
|
||||
[attr.x2]="yAxisX"
|
||||
[attr.y2]="chartHeight - chartPadding.bottom"
|
||||
stroke="#D1D5DB"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Y-axis labels -->
|
||||
<g>
|
||||
<text
|
||||
*ngFor="let label of yAxisLabels"
|
||||
[attr.x]="yAxisX - 10"
|
||||
[attr.y]="label.y + 4"
|
||||
text-anchor="end"
|
||||
class="tw-text-xs tw-fill-muted"
|
||||
>
|
||||
{{ label.value }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- X-axis line -->
|
||||
<line
|
||||
[attr.x1]="yAxisX"
|
||||
[attr.y1]="chartHeight - chartPadding.bottom"
|
||||
[attr.x2]="chartWidth - chartPadding.right"
|
||||
[attr.y2]="chartHeight - chartPadding.bottom"
|
||||
stroke="#D1D5DB"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Previous period line -->
|
||||
<path
|
||||
[attr.d]="getLinePath(chartData()!.previousPeriod)"
|
||||
fill="none"
|
||||
stroke="#9AA5B1"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="2 2"
|
||||
class="tw-transition-all"
|
||||
/>
|
||||
|
||||
<!-- Current period area -->
|
||||
<path
|
||||
[attr.d]="getAreaPath(chartData()!.currentPeriod)"
|
||||
fill="url(#currentGradient)"
|
||||
class="tw-transition-all"
|
||||
/>
|
||||
|
||||
<!-- Current period line -->
|
||||
<path
|
||||
[attr.d]="getLinePath(chartData()!.currentPeriod)"
|
||||
fill="none"
|
||||
[attr.stroke]="currentPeriodStrokeColor()"
|
||||
stroke-width="3"
|
||||
class="tw-transition-all"
|
||||
/>
|
||||
|
||||
<!-- Hover indicator line -->
|
||||
<line
|
||||
*ngIf="hoveredDataPoint()"
|
||||
[attr.x1]="hoveredDataPoint()!.x"
|
||||
[attr.y1]="chartPadding.top"
|
||||
[attr.x2]="hoveredDataPoint()!.x"
|
||||
[attr.y2]="chartHeight - chartPadding.bottom"
|
||||
class="tw-stroke-secondary-300"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Hover circles on data points -->
|
||||
<ng-container *ngIf="hoveredDataPoint()">
|
||||
<circle
|
||||
*ngFor="let point of getDataPoints(chartData()!.currentPeriod); let i = index"
|
||||
[class.tw-hidden]="i !== hoveredDataPoint()!.index"
|
||||
[attr.cx]="point.x"
|
||||
[attr.cy]="point.y"
|
||||
r="4"
|
||||
[attr.fill]="currentPeriodStrokeColor()"
|
||||
class="tw-transition-all"
|
||||
/>
|
||||
<circle
|
||||
*ngFor="let point of getDataPoints(chartData()!.previousPeriod); let i = index"
|
||||
[class.tw-hidden]="i !== hoveredDataPoint()!.index"
|
||||
[attr.cx]="point.x"
|
||||
[attr.cy]="point.y"
|
||||
r="4"
|
||||
fill="#9AA5B1"
|
||||
class="tw-transition-all"
|
||||
/>
|
||||
</ng-container>
|
||||
|
||||
<!-- X-axis labels -->
|
||||
<g>
|
||||
<text
|
||||
*ngFor="let label of xAxisLabels; let i = index"
|
||||
[attr.x]="
|
||||
yAxisX + i * ((chartWidth - yAxisX - chartPadding.right) / (xAxisLabels.length - 1))
|
||||
"
|
||||
[attr.y]="chartHeight - chartPadding.bottom + 20"
|
||||
text-anchor="middle"
|
||||
class="tw-text-xs tw-fill-main tw-select-none"
|
||||
>
|
||||
{{ label }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
*ngIf="hoveredDataPoint() && getTooltipData() as tooltip"
|
||||
class="tw-absolute tw-bg-background tw-border tw-border-secondary-300 tw-rounded-lg tw-shadow-lg tw-p-3 tw-pointer-events-none tw-z-10"
|
||||
[style.left.px]="getTooltipPosition()!.x + 10"
|
||||
[style.top.px]="getTooltipPosition()!.y"
|
||||
>
|
||||
<div class="tw-text-xs tw-font-semibold tw-text-main tw-mb-2">{{ tooltip.label }}</div>
|
||||
<div class="tw-flex tw-flex-col tw-gap-1">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<div
|
||||
class="tw-w-2.5 tw-h-2.5 tw-rounded-full"
|
||||
[style.background-color]="currentPeriodStrokeColor()"
|
||||
></div>
|
||||
<span class="tw-text-xs tw-text-muted">{{ "currentPeriod" | i18n }}:</span>
|
||||
<span class="tw-text-xs tw-font-semibold tw-text-main">{{ tooltip.currentValue }}</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<div class="tw-w-2.5 tw-h-2.5 tw-rounded-full tw-bg-secondary-300"></div>
|
||||
<span class="tw-text-xs tw-text-muted">{{ "previousPeriod" | i18n }}:</span>
|
||||
<span class="tw-text-xs tw-font-semibold tw-text-main">{{ tooltip.previousValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div *ngIf="isLoading()" class="tw-flex tw-justify-center tw-items-center tw-h-64">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
[attr.aria-label]="'loading' | i18n"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
*ngIf="hasError() && !isLoading()"
|
||||
class="tw-flex tw-flex-col tw-justify-center tw-items-center tw-h-64 tw-text-muted tw-gap-2"
|
||||
>
|
||||
<i class="bwi bwi-error bwi-2x tw-text-danger"></i>
|
||||
<span>{{ "errorLoadingData" | i18n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,381 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
DestroyRef,
|
||||
inject,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
signal,
|
||||
computed,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
MenuModule,
|
||||
BadgeModule,
|
||||
ToggleGroupModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { RiskOverTimeDataService } from "./risk-over-time-data.service";
|
||||
import { RiskMetricType, TimePeriod, RiskOverTimeData } from "./risk-over-time.models";
|
||||
|
||||
@Component({
|
||||
selector: "dirt-risk-over-time",
|
||||
templateUrl: "./risk-over-time.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
SelectModule,
|
||||
TypographyModule,
|
||||
MenuModule,
|
||||
BadgeModule,
|
||||
ToggleGroupModule,
|
||||
],
|
||||
providers: [RiskOverTimeDataService],
|
||||
})
|
||||
export class RiskOverTimeComponent implements OnInit {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
protected readonly chartSvgRef = viewChild<ElementRef<SVGElement>>("chartSvg");
|
||||
|
||||
// Data properties using signals
|
||||
protected readonly chartData = signal<RiskOverTimeData | null>(null);
|
||||
protected readonly isLoading = signal<boolean>(true);
|
||||
protected readonly hasError = signal<boolean>(false);
|
||||
protected readonly selectedMetric = signal<RiskMetricType>(RiskMetricType.Applications);
|
||||
protected readonly selectedPeriod = signal<TimePeriod>(TimePeriod.ThreeMonths);
|
||||
protected readonly hoveredDataPoint = signal<{ index: number; x: number } | null>(null);
|
||||
|
||||
// Chart dimensions
|
||||
chartWidth = 700;
|
||||
chartHeight = 280;
|
||||
chartPadding = { top: 20, right: 20, bottom: 35, left: 20 }; // Minimal left padding
|
||||
|
||||
// Dynamic Y-axis position based on label widths
|
||||
private maxYLabelWidth = 0;
|
||||
|
||||
protected get yAxisX(): number {
|
||||
// Y-axis line position: left padding + max label width + gap
|
||||
return this.chartPadding.left + this.maxYLabelWidth + 10;
|
||||
}
|
||||
|
||||
// Computed values
|
||||
protected readonly chartTitle = computed(() => {
|
||||
switch (this.selectedMetric()) {
|
||||
case RiskMetricType.Applications:
|
||||
return this.i18nService.t("applicationsAtRiskOverTime");
|
||||
case RiskMetricType.Items:
|
||||
return this.i18nService.t("itemsAtRiskOverTime");
|
||||
case RiskMetricType.Members:
|
||||
return this.i18nService.t("membersAtRiskOverTime");
|
||||
default:
|
||||
return this.i18nService.t("riskOverTime");
|
||||
}
|
||||
});
|
||||
|
||||
protected readonly isImproving = computed(() => {
|
||||
const data = this.chartData();
|
||||
if (!data) {
|
||||
return true;
|
||||
}
|
||||
const firstValue = data.currentPeriod[0];
|
||||
const lastValue = data.currentPeriod[data.currentPeriod.length - 1];
|
||||
return lastValue < firstValue;
|
||||
});
|
||||
|
||||
protected readonly currentPeriodStrokeColor = computed(() => {
|
||||
return this.isImproving() ? "#175DDC" : "#C83B3B";
|
||||
});
|
||||
|
||||
protected readonly currentPeriodColor = computed(() => {
|
||||
return this.isImproving() ? "tw-text-primary-600" : "tw-text-danger";
|
||||
});
|
||||
|
||||
// Enums for template
|
||||
readonly RiskMetricType = RiskMetricType;
|
||||
readonly TimePeriod = TimePeriod;
|
||||
|
||||
// Period options for dropdown
|
||||
periodOptions: Array<{ value: TimePeriod; label: string }> = [];
|
||||
|
||||
constructor(
|
||||
private dataService: RiskOverTimeDataService,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
// Initialize period options with i18n strings
|
||||
this.periodOptions = [
|
||||
{ value: TimePeriod.ThreeMonths, label: this.i18nService.t("last3Months") },
|
||||
{ value: TimePeriod.SixMonths, label: this.i18nService.t("last6Months") },
|
||||
{ value: TimePeriod.TwelveMonths, label: this.i18nService.t("last12Months") },
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.isLoading.set(true);
|
||||
this.hasError.set(false);
|
||||
|
||||
this.dataService
|
||||
.getRiskOverTimeData(this.selectedMetric(), this.selectedPeriod())
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.chartData.set(data);
|
||||
this.isLoading.set(false);
|
||||
// Calculate max Y-axis label width after data loads
|
||||
this.calculateMaxYLabelWidth();
|
||||
},
|
||||
error: (_error: unknown) => {
|
||||
this.hasError.set(true);
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private calculateMaxYLabelWidth(): void {
|
||||
const data = this.chartData();
|
||||
if (!data) {
|
||||
this.maxYLabelWidth = 30; // Default width
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all Y-axis label values
|
||||
const labels = this.yAxisLabels.map((l) => l.value);
|
||||
|
||||
// Create a temporary canvas to measure text width
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
this.maxYLabelWidth = 30;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set font to match the SVG text element (12px)
|
||||
context.font = "12px system-ui, -apple-system, sans-serif";
|
||||
|
||||
// Find the widest label
|
||||
this.maxYLabelWidth = Math.max(...labels.map((label) => context.measureText(label).width));
|
||||
}
|
||||
|
||||
protected onMetricChange(metric: RiskMetricType | string): void {
|
||||
this.selectedMetric.set(metric as RiskMetricType);
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
protected onPeriodChange(event: Event): void {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
this.selectedPeriod.set(target.value as TimePeriod);
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
protected get yAxisLabels(): Array<{ value: string; y: number }> {
|
||||
const data = this.chartData();
|
||||
if (!data) {
|
||||
return [
|
||||
{ value: "100%", y: this.chartPadding.top },
|
||||
{
|
||||
value: "75%",
|
||||
y:
|
||||
this.chartPadding.top +
|
||||
(this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.25,
|
||||
},
|
||||
{
|
||||
value: "50%",
|
||||
y:
|
||||
this.chartPadding.top +
|
||||
(this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.5,
|
||||
},
|
||||
{
|
||||
value: "25%",
|
||||
y:
|
||||
this.chartPadding.top +
|
||||
(this.chartHeight - this.chartPadding.top - this.chartPadding.bottom) * 0.75,
|
||||
},
|
||||
{ value: "0", y: this.chartHeight - this.chartPadding.bottom },
|
||||
];
|
||||
}
|
||||
const maxValue = Math.max(...data.currentPeriod, ...data.previousPeriod);
|
||||
const step = Math.ceil(maxValue / 4);
|
||||
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
|
||||
return [
|
||||
{ value: (step * 4).toString(), y: this.chartPadding.top },
|
||||
{ value: (step * 3).toString(), y: this.chartPadding.top + chartAreaHeight * 0.25 },
|
||||
{ value: (step * 2).toString(), y: this.chartPadding.top + chartAreaHeight * 0.5 },
|
||||
{ value: step.toString(), y: this.chartPadding.top + chartAreaHeight * 0.75 },
|
||||
{ value: "0", y: this.chartHeight - this.chartPadding.bottom },
|
||||
];
|
||||
}
|
||||
|
||||
protected get xAxisLabels(): string[] {
|
||||
return this.chartData()?.labels || [];
|
||||
}
|
||||
|
||||
// Calculate SVG path for area chart
|
||||
protected getAreaPath(data: number[], isPreviousPeriod = false): string {
|
||||
const chartData = this.chartData();
|
||||
if (!chartData || data.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod);
|
||||
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
|
||||
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
|
||||
const step = chartAreaWidth / (data.length - 1);
|
||||
|
||||
// Create path
|
||||
let path = `M ${this.yAxisX},${this.chartHeight - this.chartPadding.bottom}`;
|
||||
|
||||
// Add points
|
||||
data.forEach((value, index) => {
|
||||
const x = this.yAxisX + index * step;
|
||||
const y = this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight;
|
||||
|
||||
path += ` L ${x},${y}`;
|
||||
});
|
||||
|
||||
// Close the path
|
||||
path += ` L ${this.yAxisX + (data.length - 1) * step},${this.chartHeight - this.chartPadding.bottom}`;
|
||||
path += " Z";
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
// Calculate SVG path for line
|
||||
protected getLinePath(data: number[]): string {
|
||||
const chartData = this.chartData();
|
||||
if (!chartData || data.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod);
|
||||
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
|
||||
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
|
||||
const step = chartAreaWidth / (data.length - 1);
|
||||
|
||||
// Create path
|
||||
let path = "";
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = this.yAxisX + index * step;
|
||||
const y = this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight;
|
||||
|
||||
if (index === 0) {
|
||||
path += `M ${x},${y}`;
|
||||
} else {
|
||||
path += ` L ${x},${y}`;
|
||||
}
|
||||
});
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
// Get points for hover circles
|
||||
protected getDataPoints(data: number[]): Array<{ x: number; y: number; value: number }> {
|
||||
const chartData = this.chartData();
|
||||
if (!chartData || data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...chartData.currentPeriod, ...chartData.previousPeriod);
|
||||
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
|
||||
const chartAreaHeight = this.chartHeight - this.chartPadding.top - this.chartPadding.bottom;
|
||||
const step = chartAreaWidth / (data.length - 1);
|
||||
|
||||
return data.map((value, index) => ({
|
||||
x: this.yAxisX + index * step,
|
||||
y: this.chartPadding.top + chartAreaHeight - (value / maxValue) * chartAreaHeight,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
protected onChartMouseMove(event: MouseEvent): void {
|
||||
const data = this.chartData();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = event.currentTarget as SVGSVGElement;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
|
||||
// Find closest data point
|
||||
const chartAreaWidth = this.chartWidth - this.yAxisX - this.chartPadding.right;
|
||||
const step = chartAreaWidth / (data.currentPeriod.length - 1);
|
||||
const relativeX = x - this.yAxisX;
|
||||
const index = Math.round(relativeX / step);
|
||||
|
||||
if (index >= 0 && index < data.currentPeriod.length) {
|
||||
this.hoveredDataPoint.set({
|
||||
index,
|
||||
x: this.yAxisX + index * step,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected onChartMouseLeave(): void {
|
||||
this.hoveredDataPoint.set(null);
|
||||
}
|
||||
|
||||
protected getTooltipPosition(): { x: number; y: number } | null {
|
||||
const hovered = this.hoveredDataPoint();
|
||||
if (!hovered) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: hovered.x,
|
||||
y: this.chartPadding.top + 10,
|
||||
};
|
||||
}
|
||||
|
||||
protected getTooltipData(): {
|
||||
label: string;
|
||||
currentValue: number;
|
||||
previousValue: number;
|
||||
} | null {
|
||||
const hovered = this.hoveredDataPoint();
|
||||
const data = this.chartData();
|
||||
if (!hovered || !data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: data.labels[hovered.index],
|
||||
currentValue: data.currentPeriod[hovered.index],
|
||||
previousValue: data.previousPeriod[hovered.index],
|
||||
};
|
||||
}
|
||||
|
||||
protected downloadChartAsSVG(): void {
|
||||
const svgRef = this.chartSvgRef();
|
||||
if (!svgRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const svgElement = svgRef.nativeElement;
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgElement);
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${this.chartTitle().replace(/\s+/g, "-").toLowerCase()}.svg`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Error downloading chart - silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Risk metric types
|
||||
*/
|
||||
export const RiskMetricType = {
|
||||
Applications: "applications",
|
||||
Items: "items",
|
||||
Members: "members",
|
||||
} as const;
|
||||
|
||||
export type RiskMetricType = (typeof RiskMetricType)[keyof typeof RiskMetricType];
|
||||
|
||||
/**
|
||||
* Time period selection
|
||||
*/
|
||||
export const TimePeriod = {
|
||||
ThreeMonths: "3months",
|
||||
SixMonths: "6months",
|
||||
TwelveMonths: "12months",
|
||||
} as const;
|
||||
|
||||
export type TimePeriod = (typeof TimePeriod)[keyof typeof TimePeriod];
|
||||
|
||||
/**
|
||||
* Interface for risk over time data
|
||||
*/
|
||||
export interface RiskOverTimeData {
|
||||
/** Labels for x-axis (e.g., month names) */
|
||||
labels: string[];
|
||||
|
||||
/** Data points for current period */
|
||||
currentPeriod: number[];
|
||||
|
||||
/** Data points for previous period (last year) */
|
||||
previousPeriod: number[];
|
||||
|
||||
/** The metric type this data represents */
|
||||
metricType: RiskMetricType;
|
||||
|
||||
/** The time period this data represents */
|
||||
timePeriod: TimePeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for API response containing risk over time data
|
||||
*/
|
||||
export interface RiskOverTimeApiResponse {
|
||||
applications?: RiskOverTimeData;
|
||||
items?: RiskOverTimeData;
|
||||
members?: RiskOverTimeData;
|
||||
}
|
||||
Reference in New Issue
Block a user