diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours.tsx
index 29d509c083..d196503c58 100644
--- a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureMetricsHours/FeatureMetricsHours.tsx
@@ -3,7 +3,7 @@ import GeneralSelect, {
type IGeneralSelectProps,
} from 'component/common/GeneralSelect/GeneralSelect';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
-import { useEffect } from 'react';
+import { type ReactNode, useEffect } from 'react';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
const StyledTitle = styled('h2')(({ theme }) => ({
@@ -17,6 +17,7 @@ const StyledTitle = styled('h2')(({ theme }) => ({
interface IFeatureMetricsHoursProps {
hoursBack: number;
setHoursBack: (value: number) => void;
+ label?: ReactNode;
}
export const FEATURE_METRIC_HOURS_BACK_DEFAULT = 48;
@@ -24,6 +25,7 @@ export const FEATURE_METRIC_HOURS_BACK_DEFAULT = 48;
export const FeatureMetricsHours = ({
hoursBack,
setHoursBack,
+ label = Period,
}: IFeatureMetricsHoursProps) => {
const { trackEvent } = usePlausibleTracker();
@@ -55,7 +57,7 @@ export const FeatureMetricsHours = ({
return (
-
Period
+ {label}
i + 1),
datasets: [
{
@@ -48,73 +57,144 @@ const data = {
],
};
-const createBarChartOptions = (theme: Theme): ChartOptions<'bar'> => ({
- plugins: {
- legend: {
- position: 'bottom',
- labels: {
- color: theme.palette.text.primary,
- pointStyle: 'circle',
- usePointStyle: true,
- boxHeight: 6,
- padding: 15,
- boxPadding: 5,
- },
- },
- tooltip: {
- enabled: false,
- },
- },
- responsive: true,
- scales: {
- x: {
- stacked: true,
- ticks: {
- color: theme.palette.text.secondary,
- },
- grid: {
- display: false,
- },
- },
- y: {
- stacked: true,
- ticks: {
- color: theme.palette.text.secondary,
- maxTicksLimit: 5,
- callback: formatTickValue,
- },
- grid: {
- drawBorder: false,
- },
- },
- },
- elements: {
- bar: {
- borderRadius: 5,
- },
- },
- interaction: {
- mode: 'index',
- intersect: false,
- },
-});
-
export const PlaceholderFlagMetricsChart = () => {
const theme = useTheme();
const options = useMemo(() => {
- return createBarChartOptions(theme);
+ return createPlaceholderBarChartOptions(theme);
}, [theme]);
return (
-
+ Feature flag metrics
+
+ >
+ );
+};
+
+const useMetricsEnvironments = (project: string, flagName: string) => {
+ const [environment, setEnvironment] = useState(null);
+ const { feature } = useFeature(project, flagName);
+ const activeEnvironments = feature.environments;
+ const firstProductionEnvironment = activeEnvironments.find(
+ (env) => env.type === 'production',
+ );
+
+ useEffect(() => {
+ if (firstProductionEnvironment) {
+ setEnvironment(firstProductionEnvironment.name);
+ } else if (activeEnvironments.length > 0) {
+ setEnvironment(activeEnvironments[0].name);
+ }
+ }, [flagName]);
+
+ return { environment, setEnvironment, activeEnvironments };
+};
+
+const useFlagMetrics = (
+ flagName: string,
+ environment: string | null,
+ hoursBack: number,
+) => {
+ const { featureMetrics: metrics = [] } = useFeatureMetricsRaw(
+ flagName,
+ hoursBack,
+ );
+ const sortedMetrics = useMemo(() => {
+ return [...metrics].sort((metricA, metricB) => {
+ return metricA.timestamp.localeCompare(metricB.timestamp);
+ });
+ }, [metrics]);
+ const filteredMetrics = useMemo(() => {
+ return aggregateFeatureMetrics(
+ sortedMetrics?.filter(
+ (metric) => environment === metric.environment,
+ ),
+ ).map((metric) => ({
+ ...metric,
+ appName: 'all selected',
+ }));
+ }, [sortedMetrics, environment]);
+
+ const data = useMemo(() => {
+ return createChartData(filteredMetrics);
+ }, [filteredMetrics]);
+
+ const theme = useTheme();
+ const { locationSettings } = useLocationSettings();
+ const options = useMemo(() => {
+ return createBarChartOptions(theme, hoursBack, locationSettings);
+ }, [theme, hoursBack, locationSettings]);
+
+ return { data, options };
+};
+
+const EnvironmentSelect: FC<{
+ activeEnvironments: { name: string }[];
+ environment: string;
+ setEnvironment: (environment: string | null) => void;
+}> = ({ activeEnvironments, environment, setEnvironment }) => {
+ return (
+ ({
+ key: env.name,
+ label: env.name,
+ }))}
+ value={String(environment)}
+ onChange={setEnvironment}
/>
);
};
+const MetricsSelectors = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: theme.spacing(2),
+ mb: theme.spacing(6),
+}));
+
+export const FlagMetricsChart: FC<{
+ flag: { name: string; project: string };
+}> = ({ flag }) => {
+ const [hoursBack, setHoursBack] = useState(48);
+
+ const { environment, setEnvironment, activeEnvironments } =
+ useMetricsEnvironments(flag.project, flag.name);
+
+ const { data, options } = useFlagMetrics(flag.name, environment, hoursBack);
+
+ return (
+ <>
+
+ {environment ? (
+
+ ) : null}
+
+
+
+
+ >
+ );
+};
+
ChartJS.register(
annotationPlugin,
CategoryScale,
diff --git a/frontend/src/component/personalDashboard/PersonalDashboard.tsx b/frontend/src/component/personalDashboard/PersonalDashboard.tsx
index 8ede97688e..ff40f972e3 100644
--- a/frontend/src/component/personalDashboard/PersonalDashboard.tsx
+++ b/frontend/src/component/personalDashboard/PersonalDashboard.tsx
@@ -12,18 +12,18 @@ import {
} from '@mui/material';
import type { Theme } from '@mui/material/styles/createTheme';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
-import { type FC, useEffect, useState } from 'react';
+import React, { type FC, useEffect, useState } from 'react';
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import LinkIcon from '@mui/icons-material/Link';
import { Badge } from '../common/Badge/Badge';
import { ConnectSDK, CreateFlag } from './ConnectSDK';
-import { PlaceholderFlagMetricsChart } from './FlagMetricsChart';
import { WelcomeDialog } from './WelcomeDialog';
import { useLocalStorageState } from 'hooks/useLocalStorageState';
import useProjectOverview from 'hooks/api/getters/useProjectOverview/useProjectOverview';
import { ProjectSetupComplete } from './ProjectSetupComplete';
import { usePersonalDashboard } from 'hooks/api/getters/usePersonalDashboard/usePersonalDashboard';
import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons';
+import type { PersonalDashboardSchema } from '../../openapi';
const ScreenExplanation = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(1),
@@ -178,10 +178,12 @@ export const PersonalDashboard = () => {
const { projects, activeProject, setActiveProject } = useProjects();
const { personalDashboard } = usePersonalDashboard();
- const [activeFlag, setActiveFlag] = useState(null);
+ const [activeFlag, setActiveFlag] = useState<
+ PersonalDashboardSchema['flags'][0] | null
+ >(null);
useEffect(() => {
if (personalDashboard?.flags.length) {
- setActiveFlag(personalDashboard.flags[0].name);
+ setActiveFlag(personalDashboard.flags[0]);
}
}, [JSON.stringify(personalDashboard)]);
@@ -307,8 +309,8 @@ export const PersonalDashboard = () => {
setActiveFlag(flag.name)}
+ selected={flag.name === activeFlag?.name}
+ onClick={() => setActiveFlag(flag)}
/>
))}
@@ -321,8 +323,11 @@ export const PersonalDashboard = () => {
- Feature flag metrics
-
+ {activeFlag ? (
+
+ ) : (
+
+ )}
{
);
};
+
+const FlagMetricsChart = React.lazy(() =>
+ import('./FlagMetricsChart').then((module) => ({
+ default: module.FlagMetricsChart,
+ })),
+);
+const PlaceholderFlagMetricsChart = React.lazy(() =>
+ import('./FlagMetricsChart').then((module) => ({
+ default: module.PlaceholderFlagMetricsChart,
+ })),
+);
diff --git a/frontend/src/component/personalDashboard/createChartData.ts b/frontend/src/component/personalDashboard/createChartData.ts
new file mode 100644
index 0000000000..e154c40e9d
--- /dev/null
+++ b/frontend/src/component/personalDashboard/createChartData.ts
@@ -0,0 +1,42 @@
+import type { IFeatureMetricsRaw } from 'interfaces/featureToggle';
+import type { ChartData } from 'chart.js';
+import 'chartjs-adapter-date-fns';
+
+export interface IPoint {
+ x: string;
+ y: number;
+ variants: Record;
+}
+
+export const createChartData = (
+ metrics: IFeatureMetricsRaw[],
+): ChartData<'bar', IPoint[], string> => {
+ const yesSeries = {
+ label: 'Exposed',
+ hoverBackgroundColor: '#A39EFF',
+ backgroundColor: '#A39EFF',
+ data: createChartPoints(metrics, (m) => m.yes),
+ };
+
+ const noSeries = {
+ label: 'Not exposed',
+ hoverBackgroundColor: '#D8D6FF',
+ backgroundColor: '#D8D6FF',
+ data: createChartPoints(metrics, (m) => m.no),
+ };
+
+ return {
+ datasets: [yesSeries, noSeries],
+ };
+};
+
+const createChartPoints = (
+ metrics: IFeatureMetricsRaw[],
+ y: (m: IFeatureMetricsRaw) => number,
+): IPoint[] => {
+ return metrics.map((metric) => ({
+ x: metric.timestamp,
+ y: y(metric),
+ variants: metric.variants || {},
+ }));
+};
diff --git a/frontend/src/component/personalDashboard/createChartOptions.ts b/frontend/src/component/personalDashboard/createChartOptions.ts
new file mode 100644
index 0000000000..6203223687
--- /dev/null
+++ b/frontend/src/component/personalDashboard/createChartOptions.ts
@@ -0,0 +1,161 @@
+import type { Theme } from '@mui/material/styles/createTheme';
+import type { ChartOptions } from 'chart.js';
+import { formatTickValue } from '../common/Chart/formatTickValue';
+import type { ILocationSettings } from '../../hooks/useLocationSettings';
+import type { IPoint } from '../feature/FeatureView/FeatureMetrics/FeatureMetricsChart/createChartData';
+import {
+ formatDateHM,
+ formatDateYMD,
+ formatDateYMDHM,
+} from '../../utils/formatDate';
+
+const formatVariantEntry = (
+ variant: [string, number],
+ totalExposure: number,
+) => {
+ if (totalExposure === 0) return '';
+ const [key, value] = variant;
+ const percentage = Math.floor((Number(value) / totalExposure) * 100);
+ return `${value} (${percentage}%) - ${key}`;
+};
+
+export const createPlaceholderBarChartOptions = (
+ theme: Theme,
+): ChartOptions<'bar'> => ({
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ color: theme.palette.text.primary,
+ pointStyle: 'circle',
+ usePointStyle: true,
+ boxHeight: 6,
+ padding: 15,
+ boxPadding: 5,
+ },
+ },
+ tooltip: {
+ enabled: false,
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ stacked: true,
+ ticks: {
+ color: theme.palette.text.secondary,
+ },
+ grid: {
+ display: false,
+ },
+ },
+ y: {
+ stacked: true,
+ ticks: {
+ color: theme.palette.text.secondary,
+ maxTicksLimit: 5,
+ callback: formatTickValue,
+ },
+ grid: {
+ drawBorder: false,
+ },
+ },
+ },
+ elements: {
+ bar: {
+ borderRadius: 5,
+ },
+ },
+ interaction: {
+ mode: 'index',
+ intersect: false,
+ },
+});
+
+export const createBarChartOptions = (
+ theme: Theme,
+ hoursBack: number,
+ locationSettings: ILocationSettings,
+): ChartOptions<'bar'> => {
+ const { plugins, responsive, elements, interaction, scales } =
+ createPlaceholderBarChartOptions(theme);
+ return {
+ plugins: {
+ legend: plugins?.legend,
+ tooltip: {
+ backgroundColor: theme.palette.background.paper,
+ titleColor: theme.palette.text.primary,
+ bodyColor: theme.palette.text.primary,
+ bodySpacing: 6,
+ padding: {
+ top: 20,
+ bottom: 20,
+ left: 30,
+ right: 30,
+ },
+ borderColor: 'rgba(0, 0, 0, 0.05)',
+ borderWidth: 3,
+ usePointStyle: true,
+ caretSize: 0,
+ boxPadding: 10,
+ callbacks: {
+ label: (item) => {
+ return `${item.formattedValue} - ${item.dataset.label}`;
+ },
+ afterLabel: (item) => {
+ const data = item.dataset.data[
+ item.dataIndex
+ ] as unknown as IPoint;
+
+ if (
+ item.dataset.label !== 'Exposed' ||
+ data.variants === undefined
+ ) {
+ return '';
+ }
+ const { disabled, ...actualVariants } = data.variants;
+ return Object.entries(actualVariants)
+ .map((entry) => formatVariantEntry(entry, data.y))
+ .join('\n');
+ },
+ title: (items) => {
+ return `Time: ${
+ hoursBack > 48
+ ? formatDateYMDHM(
+ items[0].label,
+ locationSettings.locale,
+ 'UTC',
+ )
+ : formatDateHM(
+ items[0].label,
+ locationSettings.locale,
+ )
+ }`;
+ },
+ },
+ },
+ },
+ responsive,
+ scales: {
+ x: {
+ ...(scales ? scales.x : {}),
+ ticks: {
+ color: theme.palette.text.secondary,
+ callback(tickValue) {
+ const label = this.getLabelForValue(Number(tickValue));
+ return hoursBack > 48
+ ? formatDateYMD(
+ label,
+ locationSettings.locale,
+ 'UTC',
+ )
+ : formatDateHM(label, locationSettings.locale);
+ },
+ },
+ },
+ y: scales ? scales.y : {},
+ },
+ elements,
+ interaction,
+ };
+};