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, + }; +};