mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-23 13:46:45 +02:00
feat: impact metrics page with multiple charts (#10252)
This commit is contained in:
parent
0e5080fac5
commit
3f073ad457
@ -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'];
|
||||
|
344
frontend/src/component/impact-metrics/ChartConfigModal.tsx
Normal file
344
frontend/src/component/impact-metrics/ChartConfigModal.tsx
Normal file
@ -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<ChartConfig, 'id'>) => void;
|
||||
initialConfig?: ChartConfig;
|
||||
metricSeries: (ImpactMetricsSeries & { name: string })[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const ChartConfigModal: FC<ChartConfigModalProps> = ({
|
||||
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<string, string[]>
|
||||
>(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 ? (
|
||||
<NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' />
|
||||
) : (
|
||||
<NotEnoughData
|
||||
title='Select a metric series to view the chart.'
|
||||
description=''
|
||||
/>
|
||||
);
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth='lg'
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiDialog-paper': {
|
||||
minHeight: '600px',
|
||||
maxHeight: '90vh',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
{initialConfig ? 'Edit Chart' : 'Add New Chart'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', lg: 'row' },
|
||||
gap: theme.spacing(3),
|
||||
pt: theme.spacing(1),
|
||||
height: '100%',
|
||||
})}
|
||||
>
|
||||
<StyledConfigPanel>
|
||||
<TextField
|
||||
label='Chart Title (optional)'
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
fullWidth
|
||||
variant='outlined'
|
||||
size='small'
|
||||
/>
|
||||
|
||||
<ImpactMetricsControls
|
||||
selectedSeries={selectedSeries}
|
||||
onSeriesChange={handleSeriesChange}
|
||||
selectedRange={selectedRange}
|
||||
onRangeChange={setSelectedRange}
|
||||
beginAtZero={beginAtZero}
|
||||
onBeginAtZeroChange={setBeginAtZero}
|
||||
metricSeries={metricSeries}
|
||||
loading={loading}
|
||||
selectedLabels={selectedLabels}
|
||||
onLabelsChange={setSelectedLabels}
|
||||
availableLabels={currentAvailableLabels}
|
||||
/>
|
||||
</StyledConfigPanel>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<StyledPreviewPanel>
|
||||
<Typography variant='h6' color='text.secondary'>
|
||||
Preview
|
||||
</Typography>
|
||||
|
||||
{!selectedSeries && !isLoading ? (
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
Select a metric series to view the preview
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<StyledChartContainer>
|
||||
{hasError ? (
|
||||
<Alert severity='error'>
|
||||
Failed to load impact metrics. Please check
|
||||
if Prometheus is configured and the feature
|
||||
flag is enabled.
|
||||
</Alert>
|
||||
) : null}
|
||||
<LineChart
|
||||
data={
|
||||
notEnoughData || isLoading
|
||||
? placeholderData
|
||||
: data
|
||||
}
|
||||
overrideOptions={
|
||||
shouldShowPlaceholder
|
||||
? {}
|
||||
: {
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
min: minTime?.getTime(),
|
||||
max: maxTime?.getTime(),
|
||||
time: {
|
||||
unit: getTimeUnit(
|
||||
selectedRange,
|
||||
),
|
||||
displayFormats: {
|
||||
[getTimeUnit(
|
||||
selectedRange,
|
||||
)]:
|
||||
getDisplayFormat(
|
||||
selectedRange,
|
||||
),
|
||||
},
|
||||
tooltipFormat: 'PPpp',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: (
|
||||
value: unknown,
|
||||
): string | number =>
|
||||
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}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
</StyledPreviewPanel>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant='contained'
|
||||
disabled={!isValid}
|
||||
>
|
||||
{initialConfig ? 'Update' : 'Add Chart'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
211
frontend/src/component/impact-metrics/ChartItem.tsx
Normal file
211
frontend/src/component/impact-metrics/ChartItem.tsx
Normal file
@ -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<ChartItemProps> = ({ 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 = (
|
||||
<NotEnoughData description='Send impact metrics using Unleash SDK for this series to view the chart.' />
|
||||
);
|
||||
const cover = notEnoughData ? placeholder : isLoading;
|
||||
|
||||
return (
|
||||
<StyledWidget>
|
||||
<StyledHeader>
|
||||
<Box>
|
||||
{config.title && (
|
||||
<Typography variant='h6' gutterBottom>
|
||||
{config.title}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{ mb: 1 }}
|
||||
>
|
||||
{getConfigDescription(config)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<IconButton onClick={() => onEdit(config)} sx={{ mr: 1 }}>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onDelete(config.id)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</StyledHeader>
|
||||
|
||||
<StyledChartContainer>
|
||||
{hasError ? (
|
||||
<Alert severity='error'>
|
||||
Failed to load impact metrics. Please check if
|
||||
Prometheus is configured and the feature flag is
|
||||
enabled.
|
||||
</Alert>
|
||||
) : null}
|
||||
<LineChart
|
||||
data={notEnoughData || isLoading ? placeholderData : data}
|
||||
overrideOptions={
|
||||
shouldShowPlaceholder
|
||||
? {}
|
||||
: {
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
min: minTime?.getTime(),
|
||||
max: maxTime?.getTime(),
|
||||
time: {
|
||||
unit: getTimeUnit(
|
||||
config.selectedRange,
|
||||
),
|
||||
displayFormats: {
|
||||
[getTimeUnit(
|
||||
config.selectedRange,
|
||||
)]: getDisplayFormat(
|
||||
config.selectedRange,
|
||||
),
|
||||
},
|
||||
tooltipFormat: 'PPpp',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: config.beginAtZero,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: (
|
||||
value: unknown,
|
||||
): string | number =>
|
||||
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}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
</StyledWidget>
|
||||
);
|
||||
};
|
134
frontend/src/component/impact-metrics/ImpactMetrics.tsx
Normal file
134
frontend/src/component/impact-metrics/ImpactMetrics.tsx
Normal file
@ -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<ChartConfig | undefined>();
|
||||
|
||||
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<ChartConfig, 'id'>) => {
|
||||
if (editingChart) {
|
||||
updateChart(editingChart.id, config);
|
||||
} else {
|
||||
addChart(config);
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const hasError = metadataError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title='Impact Metrics'
|
||||
titleElement={
|
||||
<Typography variant='h1' component='span'>
|
||||
Impact Metrics
|
||||
</Typography>
|
||||
}
|
||||
actions={
|
||||
charts.length > 0 ? (
|
||||
<Button
|
||||
variant='contained'
|
||||
startIcon={<Add />}
|
||||
onClick={handleAddChart}
|
||||
disabled={metadataLoading || !!hasError}
|
||||
>
|
||||
Add Chart
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{charts.length === 0 && !metadataLoading && !hasError ? (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
textAlign: 'center',
|
||||
py: theme.spacing(8),
|
||||
})}
|
||||
>
|
||||
<Typography variant='h6' gutterBottom>
|
||||
No charts configured
|
||||
</Typography>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Add your first impact metrics chart to start
|
||||
tracking performance
|
||||
</Typography>
|
||||
<Button
|
||||
variant='contained'
|
||||
startIcon={<Add />}
|
||||
onClick={handleAddChart}
|
||||
disabled={metadataLoading || !!hasError}
|
||||
>
|
||||
Add Chart
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
charts.map((config) => (
|
||||
<ChartItem
|
||||
key={config.id}
|
||||
config={config}
|
||||
onEdit={handleEditChart}
|
||||
onDelete={deleteChart}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<ChartConfigModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSave={handleSaveChart}
|
||||
initialConfig={editingChart}
|
||||
metricSeries={metricSeries}
|
||||
loading={metadataLoading}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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<string, string[]>;
|
||||
onLabelsChange: (labels: Record<string, string[]>) => void;
|
||||
availableLabels?: ImpactMetricsLabels;
|
||||
};
|
||||
|
||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
|
||||
props,
|
||||
) => (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(3),
|
||||
maxWidth: 400,
|
||||
})}
|
||||
>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
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.
|
||||
</Typography>
|
||||
|
||||
<SeriesSelector
|
||||
value={props.selectedSeries}
|
||||
onChange={props.onSeriesChange}
|
||||
options={props.metricSeries}
|
||||
loading={props.loading}
|
||||
/>
|
||||
|
||||
<RangeSelector
|
||||
value={props.selectedRange}
|
||||
onChange={props.onRangeChange}
|
||||
/>
|
||||
|
||||
<BeginAtZeroToggle
|
||||
value={props.beginAtZero}
|
||||
onChange={props.onBeginAtZeroChange}
|
||||
/>
|
||||
|
||||
{props.availableLabels && (
|
||||
<LabelsFilter
|
||||
selectedLabels={props.selectedLabels}
|
||||
onChange={props.onLabelsChange}
|
||||
availableLabels={props.availableLabels}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
@ -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<BeginAtZeroToggleProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label='Begin at zero'
|
||||
/>
|
||||
);
|
@ -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<string, string[]>;
|
||||
onChange: (labels: Record<string, string[]>) => void;
|
||||
availableLabels: ImpactMetricsLabels;
|
||||
};
|
||||
|
||||
export const LabelsFilter: FC<LabelsFilterProps> = ({
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant='subtitle2'>Filter by labels</Typography>
|
||||
{Object.keys(selectedLabels).length > 0 && (
|
||||
<Chip
|
||||
label='Clear all'
|
||||
size='small'
|
||||
variant='outlined'
|
||||
onClick={clearAllLabels}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{Object.entries(availableLabels).map(([labelKey, values]) => (
|
||||
<Autocomplete
|
||||
key={labelKey}
|
||||
multiple
|
||||
options={values}
|
||||
value={selectedLabels[labelKey] || []}
|
||||
onChange={(_, newValues) =>
|
||||
handleLabelChange(labelKey, newValues)
|
||||
}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...chipProps } = getTagProps({
|
||||
index,
|
||||
});
|
||||
return (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={key}
|
||||
label={option}
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={labelKey}
|
||||
placeholder='Select values...'
|
||||
variant='outlined'
|
||||
size='small'
|
||||
/>
|
||||
)}
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -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<RangeSelectorProps> = ({ value, onChange }) => (
|
||||
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
|
||||
<InputLabel id='range-select-label'>Time</InputLabel>
|
||||
<Select
|
||||
labelId='range-select-label'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as TimeRange)}
|
||||
label='Time Range'
|
||||
>
|
||||
<MenuItem value='hour'>Last hour</MenuItem>
|
||||
<MenuItem value='day'>Last 24 hours</MenuItem>
|
||||
<MenuItem value='week'>Last 7 days</MenuItem>
|
||||
<MenuItem value='month'>Last 30 days</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
@ -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<SeriesSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
loading = false,
|
||||
}) => (
|
||||
<Autocomplete
|
||||
options={options}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={options.find((option) => option.name === value) || null}
|
||||
onChange={(_, newValue) => onChange(newValue?.name || '')}
|
||||
disabled={loading}
|
||||
renderOption={(props, option, { inputValue }) => (
|
||||
<Box component='li' {...props}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant='body2'>
|
||||
<Highlighter search={inputValue}>
|
||||
{option.name}
|
||||
</Highlighter>
|
||||
</Typography>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
<Highlighter search={inputValue}>
|
||||
{option.help}
|
||||
</Highlighter>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label='Data series'
|
||||
placeholder='Search for a metric…'
|
||||
variant='outlined'
|
||||
size='small'
|
||||
/>
|
||||
)}
|
||||
noOptionsText='No metrics available'
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
);
|
22
frontend/src/component/impact-metrics/ImpactMetricsPage.tsx
Normal file
22
frontend/src/component/impact-metrics/ImpactMetricsPage.tsx
Normal file
@ -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 = () => (
|
||||
<StyledWrapper>
|
||||
<StyledContainer>
|
||||
<ImpactMetrics />
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
108
frontend/src/component/impact-metrics/hooks/useUrlState.ts
Normal file
108
frontend/src/component/impact-metrics/hooks/useUrlState.ts
Normal file
@ -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<ImpactMetricsState>('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<ChartConfig, 'id'>) => {
|
||||
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<ChartConfig>) => {
|
||||
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,
|
||||
};
|
||||
};
|
12
frontend/src/component/impact-metrics/types.ts
Normal file
12
frontend/src/component/impact-metrics/types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type ChartConfig = {
|
||||
id: string;
|
||||
selectedSeries: string;
|
||||
selectedRange: 'hour' | 'day' | 'week' | 'month';
|
||||
beginAtZero: boolean;
|
||||
selectedLabels: Record<string, string[]>;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type ImpactMetricsState = {
|
||||
charts: ChartConfig[];
|
||||
};
|
@ -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 (
|
||||
<StyledWrapper>
|
||||
<InsightsHeader />
|
||||
<StyledContainer>
|
||||
{impactMetricsEnabled ? <ImpactMetrics /> : null}
|
||||
<LifecycleInsights />
|
||||
<PerformanceInsights />
|
||||
<UserInsights />
|
||||
|
@ -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<string>('');
|
||||
const [selectedRange, setSelectedRange] = useState<
|
||||
'hour' | 'day' | 'week' | 'month'
|
||||
>('day');
|
||||
const [beginAtZero, setBeginAtZero] = useState(false);
|
||||
const [selectedLabels, setSelectedLabels] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
|
||||
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 ? (
|
||||
<NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' />
|
||||
) : (
|
||||
<NotEnoughData
|
||||
title='Select a metric series to view the chart.'
|
||||
description=''
|
||||
/>
|
||||
);
|
||||
const cover = notEnoughData ? placeholder : isLoading;
|
||||
|
||||
return (
|
||||
<InsightsSection title='Impact metrics'>
|
||||
<StyledWidget>
|
||||
<StyledWidgetStats>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<ImpactMetricsControls
|
||||
selectedSeries={selectedSeries}
|
||||
onSeriesChange={handleSeriesChange}
|
||||
selectedRange={selectedRange}
|
||||
onRangeChange={setSelectedRange}
|
||||
beginAtZero={beginAtZero}
|
||||
onBeginAtZeroChange={setBeginAtZero}
|
||||
metricSeries={metricSeries}
|
||||
loading={metadataLoading}
|
||||
selectedLabels={selectedLabels}
|
||||
onLabelsChange={setSelectedLabels}
|
||||
availableLabels={availableLabels}
|
||||
/>
|
||||
|
||||
{!selectedSeries && !isLoading ? (
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
Select a metric series to view the chart
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</StyledWidgetStats>
|
||||
|
||||
<StyledChartContainer>
|
||||
{hasError ? (
|
||||
<Alert severity='error'>
|
||||
Failed to load impact metrics. Please check if
|
||||
Prometheus is configured and the feature flag is
|
||||
enabled.
|
||||
</Alert>
|
||||
) : null}
|
||||
<LineChart
|
||||
data={
|
||||
notEnoughData || isLoading ? placeholderData : data
|
||||
}
|
||||
overrideOptions={
|
||||
shouldShowPlaceholder
|
||||
? {}
|
||||
: {
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
min: minTime?.getTime(),
|
||||
max: maxTime?.getTime(),
|
||||
time: {
|
||||
unit: getTimeUnit(
|
||||
selectedRange,
|
||||
),
|
||||
displayFormats: {
|
||||
[getTimeUnit(
|
||||
selectedRange,
|
||||
)]:
|
||||
getDisplayFormat(
|
||||
selectedRange,
|
||||
),
|
||||
},
|
||||
tooltipFormat: 'PPpp',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: (
|
||||
value: unknown,
|
||||
): string | number =>
|
||||
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}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
</StyledWidget>
|
||||
</InsightsSection>
|
||||
);
|
||||
};
|
@ -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<string, string[]>;
|
||||
onLabelsChange: (labels: Record<string, string[]>) => void;
|
||||
availableLabels?: ImpactMetricsLabels;
|
||||
}
|
||||
|
||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
|
||||
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 (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(3),
|
||||
maxWidth: 400,
|
||||
})}
|
||||
>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
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.
|
||||
</Typography>
|
||||
|
||||
<Autocomplete
|
||||
options={metricSeries}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={
|
||||
metricSeries.find(
|
||||
(option) => option.name === selectedSeries,
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
|
||||
disabled={loading}
|
||||
renderOption={(props, option, { inputValue }) => (
|
||||
<Box component='li' {...props}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant='body2'>
|
||||
<Highlighter search={inputValue}>
|
||||
{option.name}
|
||||
</Highlighter>
|
||||
</Typography>
|
||||
<Typography
|
||||
variant='caption'
|
||||
color='text.secondary'
|
||||
>
|
||||
<Highlighter search={inputValue}>
|
||||
{option.help}
|
||||
</Highlighter>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label='Data series'
|
||||
placeholder='Search for a metric…'
|
||||
variant='outlined'
|
||||
size='small'
|
||||
/>
|
||||
)}
|
||||
noOptionsText='No metrics available'
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
|
||||
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
|
||||
<InputLabel id='range-select-label'>Time</InputLabel>
|
||||
<Select
|
||||
labelId='range-select-label'
|
||||
value={selectedRange}
|
||||
onChange={(e) =>
|
||||
onRangeChange(
|
||||
e.target.value as 'hour' | 'day' | 'week' | 'month',
|
||||
)
|
||||
}
|
||||
label='Time Range'
|
||||
>
|
||||
<MenuItem value='hour'>Last hour</MenuItem>
|
||||
<MenuItem value='day'>Last 24 hours</MenuItem>
|
||||
<MenuItem value='week'>Last 7 days</MenuItem>
|
||||
<MenuItem value='month'>Last 30 days</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={beginAtZero}
|
||||
onChange={(e) => onBeginAtZeroChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label='Begin at zero'
|
||||
/>
|
||||
{availableLabels && Object.keys(availableLabels).length > 0 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant='subtitle2'>
|
||||
Filter by labels
|
||||
</Typography>
|
||||
{Object.keys(selectedLabels).length > 0 && (
|
||||
<Chip
|
||||
label='Clear all'
|
||||
size='small'
|
||||
variant='outlined'
|
||||
onClick={clearAllLabels}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{Object.entries(availableLabels).map(
|
||||
([labelKey, values]) => (
|
||||
<Autocomplete
|
||||
key={labelKey}
|
||||
multiple
|
||||
options={values}
|
||||
value={selectedLabels[labelKey] || []}
|
||||
onChange={(_, newValues) =>
|
||||
handleLabelChange(labelKey, newValues)
|
||||
}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...chipProps } =
|
||||
getTagProps({ index });
|
||||
return (
|
||||
<Chip
|
||||
{...chipProps}
|
||||
key={key}
|
||||
label={option}
|
||||
size='small'
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={labelKey}
|
||||
placeholder='Select values...'
|
||||
variant='outlined'
|
||||
size='small'
|
||||
/>
|
||||
)}
|
||||
sx={{ minWidth: 300 }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -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,
|
||||
|
@ -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 (
|
||||
<List>
|
||||
@ -63,6 +67,9 @@ export const PrimaryNavigationList: FC<{
|
||||
{!isOss() ? (
|
||||
<PrimaryListItem href='/insights' text='Analytics' />
|
||||
) : null}
|
||||
{!isOss() && impactMetricsEnabled ? (
|
||||
<PrimaryListItem href='/impact-metrics' text='Impact Metrics' />
|
||||
) : null}
|
||||
<ConfigurationAccordion
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
|
@ -135,6 +135,17 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Analytics",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"enterprise": true,
|
||||
"flag": "impactMetrics",
|
||||
"menu": {
|
||||
"primary": true,
|
||||
},
|
||||
"path": "/impact-metrics",
|
||||
"title": "Impact metrics",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"menu": {},
|
||||
|
@ -42,6 +42,7 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte
|
||||
import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
|
||||
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
|
||||
import { Insights } from '../insights/Insights.jsx';
|
||||
import { ImpactMetricsPage } from '../impact-metrics/ImpactMetricsPage.tsx';
|
||||
import { FeedbackList } from '../feedbackNew/FeedbackList.jsx';
|
||||
import { Application } from 'component/application/Application';
|
||||
import { Signals } from 'component/signals/Signals';
|
||||
@ -159,6 +160,17 @@ export const routes: IRoute[] = [
|
||||
enterprise: true,
|
||||
},
|
||||
|
||||
// Impact Metrics
|
||||
{
|
||||
path: '/impact-metrics',
|
||||
title: 'Impact metrics',
|
||||
component: ImpactMetricsPage,
|
||||
type: 'protected',
|
||||
menu: { primary: true },
|
||||
enterprise: true,
|
||||
flag: 'impactMetrics',
|
||||
},
|
||||
|
||||
// Applications
|
||||
{
|
||||
path: '/applications/:name/*',
|
||||
|
Loading…
Reference in New Issue
Block a user