diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx index 937dd38814..51b007dae9 100644 --- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx @@ -57,6 +57,10 @@ const BreadcrumbNav = () => { return null; } + if (location.pathname === '/impact-metrics') { + return null; + } + if (paths.length === 1 && paths[0] === 'projects-archive') { // It's not possible to use `projects/archive`, because it's :projectId path paths = ['projects', 'archive']; diff --git a/frontend/src/component/impact-metrics/ChartConfigModal.tsx b/frontend/src/component/impact-metrics/ChartConfigModal.tsx new file mode 100644 index 0000000000..0acbefe985 --- /dev/null +++ b/frontend/src/component/impact-metrics/ChartConfigModal.tsx @@ -0,0 +1,344 @@ +import type { FC } from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Box, + Typography, + Alert, + styled, +} from '@mui/material'; +import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx'; +import { + LineChart, + NotEnoughData, +} from '../insights/components/LineChart/LineChart.tsx'; +import { StyledChartContainer } from 'component/insights/InsightsCharts.styles'; +import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; +import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; +import { fromUnixTime } from 'date-fns'; +import { useChartData } from './hooks/useChartData.ts'; +import type { ChartConfig } from './types.ts'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; + +export const StyledConfigPanel = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + [theme.breakpoints.down('lg')]: { + flex: 'none', + }, + [theme.breakpoints.up('lg')]: { + flex: '0 0 400px', + }, +})); + +export const StyledPreviewPanel = styled(Box)(({ theme }) => ({ + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + [theme.breakpoints.down('lg')]: { + minHeight: '300px', + }, + [theme.breakpoints.up('lg')]: { + minHeight: '400px', + }, +})); + +export interface ChartConfigModalProps { + open: boolean; + onClose: () => void; + onSave: (config: Omit) => void; + initialConfig?: ChartConfig; + metricSeries: (ImpactMetricsSeries & { name: string })[]; + loading?: boolean; +} + +export const ChartConfigModal: FC = ({ + open, + onClose, + onSave, + initialConfig, + metricSeries, + loading = false, +}) => { + const [title, setTitle] = useState(initialConfig?.title || ''); + const [selectedSeries, setSelectedSeries] = useState( + initialConfig?.selectedSeries || '', + ); + const [selectedRange, setSelectedRange] = useState< + 'hour' | 'day' | 'week' | 'month' + >(initialConfig?.selectedRange || 'day'); + const [beginAtZero, setBeginAtZero] = useState( + initialConfig?.beginAtZero || false, + ); + const [selectedLabels, setSelectedLabels] = useState< + Record + >(initialConfig?.selectedLabels || {}); + + // Data for preview + const { + data: { start, end, series: timeSeriesData }, + loading: dataLoading, + error: dataError, + } = useImpactMetricsData( + selectedSeries + ? { + series: selectedSeries, + range: selectedRange, + labels: + Object.keys(selectedLabels).length > 0 + ? selectedLabels + : undefined, + } + : undefined, + ); + + // Fetch available labels for the currently selected series + const { + data: { labels: currentAvailableLabels }, + } = useImpactMetricsData( + selectedSeries + ? { + series: selectedSeries, + range: selectedRange, + } + : undefined, + ); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const data = useChartData(timeSeriesData); + + const hasError = !!dataError; + const isLoading = dataLoading; + const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; + const notEnoughData = useMemo( + () => + !isLoading && + (!timeSeriesData || + timeSeriesData.length === 0 || + !data.datasets.some((d) => d.data.length > 1)), + [data, isLoading, timeSeriesData], + ); + + const minTime = start + ? fromUnixTime(Number.parseInt(start, 10)) + : undefined; + const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + + const placeholder = selectedSeries ? ( + + ) : ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + useEffect(() => { + if (open && initialConfig) { + setTitle(initialConfig.title || ''); + setSelectedSeries(initialConfig.selectedSeries); + setSelectedRange(initialConfig.selectedRange); + setBeginAtZero(initialConfig.beginAtZero); + setSelectedLabels(initialConfig.selectedLabels); + } else if (open && !initialConfig) { + setTitle(''); + setSelectedSeries(''); + setSelectedRange('day'); + setBeginAtZero(false); + setSelectedLabels({}); + } + }, [open, initialConfig]); + + const handleSave = () => { + if (!selectedSeries) return; + + onSave({ + title: title || undefined, + selectedSeries, + selectedRange, + beginAtZero, + selectedLabels, + }); + onClose(); + }; + + const handleSeriesChange = (series: string) => { + setSelectedSeries(series); + setSelectedLabels({}); + }; + + const isValid = selectedSeries.length > 0; + + return ( + + + {initialConfig ? 'Edit Chart' : 'Add New Chart'} + + + ({ + display: 'flex', + flexDirection: { xs: 'column', lg: 'row' }, + gap: theme.spacing(3), + pt: theme.spacing(1), + height: '100%', + })} + > + + setTitle(e.target.value)} + fullWidth + variant='outlined' + size='small' + /> + + + + + {/* Preview Panel */} + + + Preview + + + {!selectedSeries && !isLoading ? ( + + Select a metric series to view the preview + + ) : null} + + + {hasError ? ( + + Failed to load impact metrics. Please check + if Prometheus is configured and the feature + flag is enabled. + + ) : null} + + typeof value === + 'number' + ? formatLargeNumbers( + value, + ) + : (value as number), + }, + }, + }, + plugins: { + legend: { + display: + timeSeriesData && + timeSeriesData.length > + 1, + position: + 'bottom' as const, + labels: { + usePointStyle: true, + boxWidth: 8, + padding: 12, + }, + }, + }, + animations: { + x: { duration: 0 }, + y: { duration: 0 }, + }, + } + } + cover={cover} + /> + + + + + + + + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ChartItem.tsx b/frontend/src/component/impact-metrics/ChartItem.tsx new file mode 100644 index 0000000000..0d8f305550 --- /dev/null +++ b/frontend/src/component/impact-metrics/ChartItem.tsx @@ -0,0 +1,211 @@ +import type { FC } from 'react'; +import { useMemo } from 'react'; +import { + Box, + Typography, + IconButton, + Alert, + styled, + Paper, +} from '@mui/material'; +import Edit from '@mui/icons-material/Edit'; +import Delete from '@mui/icons-material/Delete'; +import { + LineChart, + NotEnoughData, +} from '../insights/components/LineChart/LineChart.tsx'; +import { StyledChartContainer } from 'component/insights/InsightsCharts.styles'; +import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js'; +import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; +import { fromUnixTime } from 'date-fns'; +import { useChartData } from './hooks/useChartData.ts'; +import type { ChartConfig } from './types.ts'; + +export interface ChartItemProps { + config: ChartConfig; + onEdit: (config: ChartConfig) => void; + onDelete: (id: string) => void; +} + +const getConfigDescription = (config: ChartConfig): string => { + const parts: string[] = []; + + if (config.selectedSeries) { + parts.push(`Series: ${config.selectedSeries}`); + } + + parts.push(`Time range: last ${config.selectedRange}`); + + if (config.beginAtZero) { + parts.push('Begin at zero'); + } + + const labelCount = Object.keys(config.selectedLabels).length; + if (labelCount > 0) { + parts.push(`${labelCount} label filter${labelCount > 1 ? 's' : ''}`); + } + + return parts.join(' • '); +}; + +const StyledHeader = styled(Typography)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.spacing(2, 3), +})); + +const StyledWidget = styled(Paper)(({ theme }) => ({ + borderRadius: `${theme.shape.borderRadiusLarge}px`, + boxShadow: 'none', + display: 'flex', + flexDirection: 'column', +})); + +export const ChartItem: FC = ({ config, onEdit, onDelete }) => { + const { + data: { start, end, series: timeSeriesData }, + loading: dataLoading, + error: dataError, + } = useImpactMetricsData({ + series: config.selectedSeries, + range: config.selectedRange, + labels: + Object.keys(config.selectedLabels).length > 0 + ? config.selectedLabels + : undefined, + }); + + const placeholderData = usePlaceholderData({ + fill: true, + type: 'constant', + }); + + const data = useChartData(timeSeriesData); + + const hasError = !!dataError; + const isLoading = dataLoading; + const shouldShowPlaceholder = isLoading || hasError; + const notEnoughData = useMemo( + () => + !isLoading && + (!timeSeriesData || + timeSeriesData.length === 0 || + !data.datasets.some((d) => d.data.length > 1)), + [data, isLoading, timeSeriesData], + ); + + const minTime = start + ? fromUnixTime(Number.parseInt(start, 10)) + : undefined; + const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; + + const placeholder = ( + + ); + const cover = notEnoughData ? placeholder : isLoading; + + return ( + + + + {config.title && ( + + {config.title} + + )} + + {getConfigDescription(config)} + + + + onEdit(config)} sx={{ mr: 1 }}> + + + onDelete(config.id)}> + + + + + + + {hasError ? ( + + Failed to load impact metrics. Please check if + Prometheus is configured and the feature flag is + enabled. + + ) : null} + + typeof value === 'number' + ? formatLargeNumbers( + value, + ) + : (value as number), + }, + }, + }, + plugins: { + legend: { + display: + timeSeriesData && + timeSeriesData.length > 1, + position: 'bottom' as const, + labels: { + usePointStyle: true, + boxWidth: 8, + padding: 12, + }, + }, + }, + animations: { + x: { duration: 0 }, + y: { duration: 0 }, + }, + } + } + cover={cover} + /> + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/impact-metrics/ImpactMetrics.tsx new file mode 100644 index 0000000000..a070ad2092 --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetrics.tsx @@ -0,0 +1,134 @@ +import type { FC } from 'react'; +import { useMemo, useState } from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import Add from '@mui/icons-material/Add'; +import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; +import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { ChartConfigModal } from './ChartConfigModal.tsx'; +import { ChartItem } from './ChartItem.tsx'; +import { useUrlState } from './hooks/useUrlState.ts'; +import type { ChartConfig } from './types.ts'; + +export const ImpactMetrics: FC = () => { + const [modalOpen, setModalOpen] = useState(false); + const [editingChart, setEditingChart] = useState(); + + const { charts, addChart, updateChart, deleteChart } = useUrlState(); + + const { + metadata, + loading: metadataLoading, + error: metadataError, + } = useImpactMetricsMetadata(); + + const metricSeries = useMemo(() => { + if (!metadata?.series) { + return []; + } + return Object.entries(metadata.series).map(([name, rest]) => ({ + name, + ...rest, + })); + }, [metadata]); + + const handleAddChart = () => { + setEditingChart(undefined); + setModalOpen(true); + }; + + const handleEditChart = (config: ChartConfig) => { + setEditingChart(config); + setModalOpen(true); + }; + + const handleSaveChart = (config: Omit) => { + if (editingChart) { + updateChart(editingChart.id, config); + } else { + addChart(config); + } + setModalOpen(false); + }; + + const hasError = metadataError; + + return ( + <> + + Impact Metrics + + } + actions={ + charts.length > 0 ? ( + + ) : null + } + /> + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + width: '100%', + })} + > + {charts.length === 0 && !metadataLoading && !hasError ? ( + ({ + textAlign: 'center', + py: theme.spacing(8), + })} + > + + No charts configured + + + Add your first impact metrics chart to start + tracking performance + + + + ) : ( + charts.map((config) => ( + + )) + )} + + setModalOpen(false)} + onSave={handleSaveChart} + initialConfig={editingChart} + metricSeries={metricSeries} + loading={metadataLoading} + /> + + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx new file mode 100644 index 0000000000..206efe8ca7 --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/ImpactMetricsControls.tsx @@ -0,0 +1,66 @@ +import type { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; +import { SeriesSelector } from './components/SeriesSelector.tsx'; +import { RangeSelector, type TimeRange } from './components/RangeSelector.tsx'; +import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx'; +import { LabelsFilter } from './components/LabelsFilter.tsx'; + +export type ImpactMetricsControlsProps = { + selectedSeries: string; + onSeriesChange: (series: string) => void; + selectedRange: TimeRange; + onRangeChange: (range: TimeRange) => void; + beginAtZero: boolean; + onBeginAtZeroChange: (beginAtZero: boolean) => void; + metricSeries: (ImpactMetricsSeries & { name: string })[]; + loading?: boolean; + selectedLabels: Record; + onLabelsChange: (labels: Record) => void; + availableLabels?: ImpactMetricsLabels; +}; + +export const ImpactMetricsControls: FC = ( + props, +) => ( + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + maxWidth: 400, + })} + > + + Select a custom metric to see its value over time. This can help you + understand the impact of your feature rollout on key outcomes, such + as system performance, usage patterns or error rates. + + + + + + + + + {props.availableLabels && ( + + )} + +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx new file mode 100644 index 0000000000..7fb884f4ba --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/BeginAtZeroToggle.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; +import { FormControlLabel, Checkbox } from '@mui/material'; + +export type BeginAtZeroToggleProps = { + value: boolean; + onChange: (beginAtZero: boolean) => void; +}; + +export const BeginAtZeroToggle: FC = ({ + value, + onChange, +}) => ( + onChange(e.target.checked)} + /> + } + label='Begin at zero' + /> +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/LabelsFilter.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/LabelsFilter.tsx new file mode 100644 index 0000000000..b65d09f22a --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/LabelsFilter.tsx @@ -0,0 +1,86 @@ +import type { FC } from 'react'; +import { Box, Autocomplete, TextField, Typography, Chip } from '@mui/material'; +import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; + +export type LabelsFilterProps = { + selectedLabels: Record; + onChange: (labels: Record) => void; + availableLabels: ImpactMetricsLabels; +}; + +export const LabelsFilter: FC = ({ + selectedLabels, + onChange, + availableLabels, +}) => { + const handleLabelChange = (labelKey: string, values: string[]) => { + const newLabels = { ...selectedLabels }; + if (values.length === 0) { + delete newLabels[labelKey]; + } else { + newLabels[labelKey] = values; + } + onChange(newLabels); + }; + + const clearAllLabels = () => { + onChange({}); + }; + + if (!availableLabels || Object.keys(availableLabels).length === 0) { + return null; + } + + return ( + + + Filter by labels + {Object.keys(selectedLabels).length > 0 && ( + + )} + + + {Object.entries(availableLabels).map(([labelKey, values]) => ( + + handleLabelChange(labelKey, newValues) + } + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...chipProps } = getTagProps({ + index, + }); + return ( + + ); + }) + } + renderInput={(params) => ( + + )} + sx={{ minWidth: 300 }} + /> + ))} + + ); +}; diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/RangeSelector.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/RangeSelector.tsx new file mode 100644 index 0000000000..86f204f3d9 --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/RangeSelector.tsx @@ -0,0 +1,26 @@ +import type { FC } from 'react'; +import { FormControl, InputLabel, Select, MenuItem } from '@mui/material'; + +export type TimeRange = 'hour' | 'day' | 'week' | 'month'; + +export type RangeSelectorProps = { + value: TimeRange; + onChange: (range: TimeRange) => void; +}; + +export const RangeSelector: FC = ({ value, onChange }) => ( + + Time + + +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx new file mode 100644 index 0000000000..cb7bebed9f --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsControls/components/SeriesSelector.tsx @@ -0,0 +1,55 @@ +import type { FC } from 'react'; +import { Autocomplete, TextField, Typography, Box } from '@mui/material'; +import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { Highlighter } from 'component/common/Highlighter/Highlighter'; + +type SeriesOption = ImpactMetricsSeries & { name: string }; + +export type SeriesSelectorProps = { + value: string; + onChange: (series: string) => void; + options: SeriesOption[]; + loading?: boolean; +}; + +export const SeriesSelector: FC = ({ + value, + onChange, + options, + loading = false, +}) => ( + option.name} + value={options.find((option) => option.name === value) || null} + onChange={(_, newValue) => onChange(newValue?.name || '')} + disabled={loading} + renderOption={(props, option, { inputValue }) => ( + + + + + {option.name} + + + + + {option.help} + + + + + )} + renderInput={(params) => ( + + )} + noOptionsText='No metrics available' + sx={{ minWidth: 300 }} + /> +); diff --git a/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx b/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx new file mode 100644 index 0000000000..902048694d --- /dev/null +++ b/frontend/src/component/impact-metrics/ImpactMetricsPage.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; +import { styled } from '@mui/material'; +import { ImpactMetrics } from './ImpactMetrics.tsx'; + +const StyledWrapper = styled('div')(({ theme }) => ({ + paddingTop: theme.spacing(2), +})); + +const StyledContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(4), + paddingBottom: theme.spacing(4), +})); + +export const ImpactMetricsPage: FC = () => ( + + + + + +); diff --git a/frontend/src/component/insights/impact-metrics/hooks/useChartData.ts b/frontend/src/component/impact-metrics/hooks/useChartData.ts similarity index 100% rename from frontend/src/component/insights/impact-metrics/hooks/useChartData.ts rename to frontend/src/component/impact-metrics/hooks/useChartData.ts diff --git a/frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts b/frontend/src/component/impact-metrics/hooks/useSeriesColor.ts similarity index 100% rename from frontend/src/component/insights/impact-metrics/hooks/useSeriesColor.ts rename to frontend/src/component/impact-metrics/hooks/useSeriesColor.ts diff --git a/frontend/src/component/impact-metrics/hooks/useUrlState.ts b/frontend/src/component/impact-metrics/hooks/useUrlState.ts new file mode 100644 index 0000000000..275c9458f2 --- /dev/null +++ b/frontend/src/component/impact-metrics/hooks/useUrlState.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useLocalStorageState } from 'hooks/useLocalStorageState'; +import type { ChartConfig, ImpactMetricsState } from '../types.ts'; + +const encodeState = ( + state: ImpactMetricsState | null | undefined, +): string | undefined => + state && state.charts.length > 0 ? btoa(JSON.stringify(state)) : undefined; + +const decodeState = ( + value: string | (string | null)[] | null | undefined, +): ImpactMetricsState | null => { + if (typeof value !== 'string') return null; + try { + return JSON.parse(atob(value)); + } catch { + return null; + } +}; + +export const useUrlState = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [storedState, setStoredState] = + useLocalStorageState('impact-metrics-state', { + charts: [], + }); + + const urlState = decodeState(searchParams.get('data')); + const currentState = urlState || storedState; + + useEffect(() => { + if (urlState) { + setStoredState(urlState); + } else if (storedState.charts.length > 0) { + const encoded = encodeState(storedState); + if (encoded) { + setSearchParams( + (prev) => { + prev.set('data', encoded); + return prev; + }, + { replace: true }, + ); + } + } + }, [urlState, storedState.charts.length, setStoredState, setSearchParams]); + + const updateState = useCallback( + (newState: ImpactMetricsState) => { + setStoredState(newState); + setSearchParams( + (prev) => { + const encoded = encodeState(newState); + if (encoded) { + prev.set('data', encoded); + } else { + prev.delete('data'); + } + return prev; + }, + { replace: true }, + ); + }, + [setStoredState, setSearchParams], + ); + + const addChart = useCallback( + (config: Omit) => { + const newChart: ChartConfig = { + ...config, + id: `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + }; + + updateState({ + charts: [...currentState.charts, newChart], + }); + }, + [currentState.charts, updateState], + ); + + const updateChart = useCallback( + (id: string, updates: Partial) => { + updateState({ + charts: currentState.charts.map((chart) => + chart.id === id ? { ...chart, ...updates } : chart, + ), + }); + }, + [currentState.charts, updateState], + ); + + const deleteChart = useCallback( + (id: string) => { + updateState({ + charts: currentState.charts.filter((chart) => chart.id !== id), + }); + }, + [currentState.charts, updateState], + ); + + return { + charts: currentState.charts, + addChart, + updateChart, + deleteChart, + }; +}; diff --git a/frontend/src/component/impact-metrics/types.ts b/frontend/src/component/impact-metrics/types.ts new file mode 100644 index 0000000000..c319a3c463 --- /dev/null +++ b/frontend/src/component/impact-metrics/types.ts @@ -0,0 +1,12 @@ +export type ChartConfig = { + id: string; + selectedSeries: string; + selectedRange: 'hour' | 'day' | 'week' | 'month'; + beginAtZero: boolean; + selectedLabels: Record; + title?: string; +}; + +export type ImpactMetricsState = { + charts: ChartConfig[]; +}; diff --git a/frontend/src/component/insights/impact-metrics/utils.ts b/frontend/src/component/impact-metrics/utils.ts similarity index 100% rename from frontend/src/component/insights/impact-metrics/utils.ts rename to frontend/src/component/impact-metrics/utils.ts diff --git a/frontend/src/component/insights/Insights.tsx b/frontend/src/component/insights/Insights.tsx index f5936916d9..76ab5b3e92 100644 --- a/frontend/src/component/insights/Insights.tsx +++ b/frontend/src/component/insights/Insights.tsx @@ -7,20 +7,16 @@ import { StyledContainer } from './InsightsCharts.styles.ts'; import { LifecycleInsights } from './sections/LifecycleInsights.tsx'; import { PerformanceInsights } from './sections/PerformanceInsights.tsx'; import { UserInsights } from './sections/UserInsights.tsx'; -import { ImpactMetrics } from './impact-metrics/ImpactMetrics.tsx'; const StyledWrapper = styled('div')(({ theme }) => ({ paddingTop: theme.spacing(2), })); const NewInsights: FC = () => { - const impactMetricsEnabled = useUiFlag('impactMetrics'); - return ( - {impactMetricsEnabled ? : null} diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx deleted file mode 100644 index 816cb4521e..0000000000 --- a/frontend/src/component/insights/impact-metrics/ImpactMetrics.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import type { FC } from 'react'; -import { useMemo, useState } from 'react'; -import { Box, Typography, Alert } from '@mui/material'; -import { - LineChart, - NotEnoughData, -} from '../components/LineChart/LineChart.tsx'; -import { InsightsSection } from '../sections/InsightsSection.tsx'; -import { - StyledChartContainer, - StyledWidget, - StyledWidgetStats, -} from 'component/insights/InsightsCharts.styles'; -import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; -import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; -import { usePlaceholderData } from '../hooks/usePlaceholderData.js'; -import { ImpactMetricsControls } from './ImpactMetricsControls.tsx'; -import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts'; -import { fromUnixTime } from 'date-fns'; -import { useChartData } from './hooks/useChartData.ts'; - -export const ImpactMetrics: FC = () => { - const [selectedSeries, setSelectedSeries] = useState(''); - const [selectedRange, setSelectedRange] = useState< - 'hour' | 'day' | 'week' | 'month' - >('day'); - const [beginAtZero, setBeginAtZero] = useState(false); - const [selectedLabels, setSelectedLabels] = useState< - Record - >({}); - - const handleSeriesChange = (series: string) => { - setSelectedSeries(series); - setSelectedLabels({}); // labels are series-specific - }; - - const { - metadata, - loading: metadataLoading, - error: metadataError, - } = useImpactMetricsMetadata(); - const { - data: { start, end, series: timeSeriesData, labels: availableLabels }, - loading: dataLoading, - error: dataError, - } = useImpactMetricsData( - selectedSeries - ? { - series: selectedSeries, - range: selectedRange, - labels: - Object.keys(selectedLabels).length > 0 - ? selectedLabels - : undefined, - } - : undefined, - ); - - const placeholderData = usePlaceholderData({ - fill: true, - type: 'constant', - }); - - const metricSeries = useMemo(() => { - if (!metadata?.series) { - return []; - } - return Object.entries(metadata.series).map(([name, rest]) => ({ - name, - ...rest, - })); - }, [metadata]); - - const data = useChartData(timeSeriesData); - - const hasError = metadataError || dataError; - const isLoading = metadataLoading || dataLoading; - const shouldShowPlaceholder = !selectedSeries || isLoading || hasError; - const notEnoughData = useMemo( - () => - !isLoading && - (!timeSeriesData || - timeSeriesData.length === 0 || - !data.datasets.some((d) => d.data.length > 1)), - [data, isLoading, timeSeriesData], - ); - - const minTime = start - ? fromUnixTime(Number.parseInt(start, 10)) - : undefined; - const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined; - - const placeholder = selectedSeries ? ( - - ) : ( - - ); - const cover = notEnoughData ? placeholder : isLoading; - - return ( - - - - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - width: '100%', - })} - > - - - {!selectedSeries && !isLoading ? ( - - Select a metric series to view the chart - - ) : null} - - - - - {hasError ? ( - - Failed to load impact metrics. Please check if - Prometheus is configured and the feature flag is - enabled. - - ) : null} - - typeof value === 'number' - ? formatLargeNumbers( - value, - ) - : (value as number), - }, - }, - }, - plugins: { - legend: { - display: - timeSeriesData && - timeSeriesData.length > 1, - position: 'bottom' as const, - labels: { - usePointStyle: true, - boxWidth: 8, - padding: 12, - }, - }, - }, - animations: { - x: { duration: 0 }, - y: { duration: 0 }, - }, - } - } - cover={cover} - /> - - - - ); -}; diff --git a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx b/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx deleted file mode 100644 index e658970d7c..0000000000 --- a/frontend/src/component/insights/impact-metrics/ImpactMetricsControls.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import type { FC } from 'react'; -import { - FormControl, - InputLabel, - Select, - MenuItem, - FormControlLabel, - Checkbox, - Box, - Autocomplete, - TextField, - Typography, - Chip, -} from '@mui/material'; -import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; -import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; -import { Highlighter } from 'component/common/Highlighter/Highlighter'; - -export interface ImpactMetricsControlsProps { - selectedSeries: string; - onSeriesChange: (series: string) => void; - selectedRange: 'hour' | 'day' | 'week' | 'month'; - onRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void; - beginAtZero: boolean; - onBeginAtZeroChange: (beginAtZero: boolean) => void; - metricSeries: (ImpactMetricsSeries & { name: string })[]; - loading?: boolean; - selectedLabels: Record; - onLabelsChange: (labels: Record) => void; - availableLabels?: ImpactMetricsLabels; -} - -export const ImpactMetricsControls: FC = ({ - selectedSeries, - onSeriesChange, - selectedRange, - onRangeChange, - beginAtZero, - onBeginAtZeroChange, - metricSeries, - loading = false, - selectedLabels, - onLabelsChange, - availableLabels, -}) => { - const handleLabelChange = (labelKey: string, values: string[]) => { - const newLabels = { ...selectedLabels }; - if (values.length === 0) { - delete newLabels[labelKey]; - } else { - newLabels[labelKey] = values; - } - onLabelsChange(newLabels); - }; - - const clearAllLabels = () => { - onLabelsChange({}); - }; - - return ( - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(3), - maxWidth: 400, - })} - > - - Select a custom metric to see its value over time. This can help - you understand the impact of your feature rollout on key - outcomes, such as system performance, usage patterns or error - rates. - - - option.name} - value={ - metricSeries.find( - (option) => option.name === selectedSeries, - ) || null - } - onChange={(_, newValue) => onSeriesChange(newValue?.name || '')} - disabled={loading} - renderOption={(props, option, { inputValue }) => ( - - - - - {option.name} - - - - - {option.help} - - - - - )} - renderInput={(params) => ( - - )} - noOptionsText='No metrics available' - sx={{ minWidth: 300 }} - /> - - - Time - - - - onBeginAtZeroChange(e.target.checked)} - /> - } - label='Begin at zero' - /> - {availableLabels && Object.keys(availableLabels).length > 0 ? ( - - - - Filter by labels - - {Object.keys(selectedLabels).length > 0 && ( - - )} - - - {Object.entries(availableLabels).map( - ([labelKey, values]) => ( - - handleLabelChange(labelKey, newValues) - } - renderTags={(value, getTagProps) => - value.map((option, index) => { - const { key, ...chipProps } = - getTagProps({ index }); - return ( - - ); - }) - } - renderInput={(params) => ( - - )} - sx={{ minWidth: 300 }} - /> - ), - )} - - ) : null} - - ); -}; diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx index bec601a2af..447d542d22 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/IconRenderer.tsx @@ -15,6 +15,7 @@ import GroupsIcon from '@mui/icons-material/GroupsOutlined'; import RoleIcon from '@mui/icons-material/AdminPanelSettingsOutlined'; import SettingsIcon from '@mui/icons-material/Settings'; import InsightsIcon from '@mui/icons-material/Insights'; +import ImpactMetricsIcon from '@mui/icons-material/TrendingUpOutlined'; import ApiAccessIcon from '@mui/icons-material/KeyOutlined'; import SingleSignOnIcon from '@mui/icons-material/AssignmentOutlined'; import NetworkIcon from '@mui/icons-material/HubOutlined'; @@ -44,6 +45,7 @@ const icons: Record< > = { '/search': FlagOutlinedIcon, '/insights': InsightsIcon, + '/impact-metrics': ImpactMetricsIcon, '/applications': ApplicationsIcon, '/context': ContextFieldsIcon, '/feature-toggle-type': FlagTypesIcon, diff --git a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx index bd77f94970..fc73828574 100644 --- a/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx +++ b/frontend/src/component/layout/MainLayout/NavigationSidebar/NavigationList.tsx @@ -12,6 +12,8 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useNewAdminMenu } from 'hooks/useNewAdminMenu'; import { AdminMenuNavigation } from '../AdminMenu/AdminNavigationItems.tsx'; import { ConfigurationAccordion } from './ConfigurationAccordion.tsx'; +import { useRoutes } from './useRoutes.ts'; +import { useUiFlag } from 'hooks/useUiFlag.ts'; export const OtherLinksList = () => { const { uiConfig } = useUiConfig(); @@ -38,6 +40,7 @@ export const PrimaryNavigationList: FC<{ onClick: (activeItem: string) => void; activeItem?: string; }> = ({ mode, setMode, onClick, activeItem }) => { + const { routes } = useRoutes(); const PrimaryListItem = ({ href, text, @@ -53,6 +56,7 @@ export const PrimaryNavigationList: FC<{ ); const { isOss } = useUiConfig(); + const impactMetricsEnabled = useUiFlag('impactMetrics'); return ( @@ -63,6 +67,9 @@ export const PrimaryNavigationList: FC<{ {!isOss() ? ( ) : null} + {!isOss() && impactMetricsEnabled ? ( + + ) : null}