mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-27 13:49:10 +02:00
url handling
This commit is contained in:
parent
ed73f76092
commit
6f5908766e
334
frontend/src/component/impact-metrics/ChartConfigModal.tsx
Normal file
334
frontend/src/component/impact-metrics/ChartConfigModal.tsx
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ImpactMetricsControls } from './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 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%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* Configuration Panel */}
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
flex: { xs: 'none', lg: '0 0 400px' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: theme.spacing(3),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Preview Panel */}
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: theme.spacing(2),
|
||||||
|
minHeight: { xs: '300px', lg: '400px' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant='contained'
|
||||||
|
disabled={!isValid}
|
||||||
|
>
|
||||||
|
{initialConfig ? 'Update' : 'Add Chart'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
210
frontend/src/component/impact-metrics/ChartItem.tsx
Normal file
210
frontend/src/component/impact-metrics/ChartItem.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
styled,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Edit, Delete } from '@mui/icons-material';
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,64 +1,25 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Box, Typography, Alert } from '@mui/material';
|
import { Box, Typography, Button } from '@mui/material';
|
||||||
import {
|
import { Add } from '@mui/icons-material';
|
||||||
LineChart,
|
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
|
||||||
NotEnoughData,
|
|
||||||
} from '../insights/components/LineChart/LineChart.tsx';
|
|
||||||
import {
|
|
||||||
StyledChartContainer,
|
|
||||||
StyledWidget,
|
|
||||||
StyledWidgetStats,
|
|
||||||
} from 'component/insights/InsightsCharts.styles';
|
|
||||||
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
|
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
|
||||||
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
|
import { ChartConfigModal } from './ChartConfigModal.tsx';
|
||||||
import { usePlaceholderData } from '../insights/hooks/usePlaceholderData.js';
|
import { ChartItem } from './ChartItem.tsx';
|
||||||
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
|
import { useUrlState } from './hooks/useUrlState.ts';
|
||||||
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
|
import type { ChartConfig } from './types.ts';
|
||||||
import { fromUnixTime } from 'date-fns';
|
|
||||||
import { useChartData } from './hooks/useChartData.ts';
|
|
||||||
|
|
||||||
export const ImpactMetrics: FC = () => {
|
export const ImpactMetrics: FC = () => {
|
||||||
const [selectedSeries, setSelectedSeries] = useState<string>('');
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [selectedRange, setSelectedRange] = useState<
|
const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
|
||||||
'hour' | 'day' | 'week' | 'month'
|
|
||||||
>('day');
|
|
||||||
const [beginAtZero, setBeginAtZero] = useState(false);
|
|
||||||
const [selectedLabels, setSelectedLabels] = useState<
|
|
||||||
Record<string, string[]>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const handleSeriesChange = (series: string) => {
|
const { charts, addChart, updateChart, deleteChart } = useUrlState();
|
||||||
setSelectedSeries(series);
|
|
||||||
setSelectedLabels({}); // labels are series-specific
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
metadata,
|
metadata,
|
||||||
loading: metadataLoading,
|
loading: metadataLoading,
|
||||||
error: metadataError,
|
error: metadataError,
|
||||||
} = useImpactMetricsMetadata();
|
} = 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(() => {
|
const metricSeries = useMemo(() => {
|
||||||
if (!metadata?.series) {
|
if (!metadata?.series) {
|
||||||
@ -70,138 +31,104 @@ export const ImpactMetrics: FC = () => {
|
|||||||
}));
|
}));
|
||||||
}, [metadata]);
|
}, [metadata]);
|
||||||
|
|
||||||
const data = useChartData(timeSeriesData);
|
const handleAddChart = () => {
|
||||||
|
setEditingChart(undefined);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const hasError = metadataError || dataError;
|
const handleEditChart = (config: ChartConfig) => {
|
||||||
const isLoading = metadataLoading || dataLoading;
|
setEditingChart(config);
|
||||||
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
|
setModalOpen(true);
|
||||||
const notEnoughData = useMemo(
|
};
|
||||||
() =>
|
|
||||||
!isLoading &&
|
|
||||||
(!timeSeriesData ||
|
|
||||||
timeSeriesData.length === 0 ||
|
|
||||||
!data.datasets.some((d) => d.data.length > 1)),
|
|
||||||
[data, isLoading, timeSeriesData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const minTime = start
|
const handleSaveChart = (config: Omit<ChartConfig, 'id'>) => {
|
||||||
? fromUnixTime(Number.parseInt(start, 10))
|
if (editingChart) {
|
||||||
: undefined;
|
updateChart(editingChart.id, config);
|
||||||
const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
|
} else {
|
||||||
|
addChart(config);
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const placeholder = selectedSeries ? (
|
const hasError = metadataError;
|
||||||
<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 (
|
return (
|
||||||
<StyledWidget>
|
<>
|
||||||
<StyledWidgetStats>
|
<PageHeader
|
||||||
<Box
|
title='Impact Metrics'
|
||||||
sx={(theme) => ({
|
titleElement={
|
||||||
display: 'flex',
|
<Typography variant='h1' component='span'>
|
||||||
flexDirection: 'column',
|
Impact Metrics
|
||||||
gap: theme.spacing(2),
|
</Typography>
|
||||||
width: '100%',
|
}
|
||||||
})}
|
actions={
|
||||||
>
|
charts.length > 0 ? (
|
||||||
<ImpactMetricsControls
|
<Button
|
||||||
selectedSeries={selectedSeries}
|
variant='contained'
|
||||||
onSeriesChange={handleSeriesChange}
|
startIcon={<Add />}
|
||||||
selectedRange={selectedRange}
|
onClick={handleAddChart}
|
||||||
onRangeChange={setSelectedRange}
|
disabled={metadataLoading || !!hasError}
|
||||||
beginAtZero={beginAtZero}
|
>
|
||||||
onBeginAtZeroChange={setBeginAtZero}
|
Add Chart
|
||||||
metricSeries={metricSeries}
|
</Button>
|
||||||
loading={metadataLoading}
|
) : null
|
||||||
selectedLabels={selectedLabels}
|
}
|
||||||
onLabelsChange={setSelectedLabels}
|
/>
|
||||||
availableLabels={availableLabels}
|
<Box
|
||||||
/>
|
sx={(theme) => ({
|
||||||
|
display: 'flex',
|
||||||
{!selectedSeries && !isLoading ? (
|
flexDirection: 'column',
|
||||||
<Typography variant='body2' color='text.secondary'>
|
gap: theme.spacing(2),
|
||||||
Select a metric series to view the chart
|
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>
|
||||||
) : null}
|
<Typography
|
||||||
</Box>
|
variant='body2'
|
||||||
</StyledWidgetStats>
|
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}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<StyledChartContainer>
|
<ChartConfigModal
|
||||||
{hasError ? (
|
open={modalOpen}
|
||||||
<Alert severity='error'>
|
onClose={() => setModalOpen(false)}
|
||||||
Failed to load impact metrics. Please check if
|
onSave={handleSaveChart}
|
||||||
Prometheus is configured and the feature flag is
|
initialConfig={editingChart}
|
||||||
enabled.
|
metricSeries={metricSeries}
|
||||||
</Alert>
|
loading={metadataLoading}
|
||||||
) : 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>
|
</Box>
|
||||||
</StyledWidget>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { styled, Typography } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { ImpactMetrics } from './ImpactMetrics.tsx';
|
import { ImpactMetrics } from './ImpactMetrics.tsx';
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
|
|
||||||
|
|
||||||
const StyledWrapper = styled('div')(({ theme }) => ({
|
const StyledWrapper = styled('div')(({ theme }) => ({
|
||||||
paddingTop: theme.spacing(2),
|
paddingTop: theme.spacing(2),
|
||||||
@ -14,19 +13,9 @@ const StyledContainer = styled('div')(({ theme }) => ({
|
|||||||
paddingBottom: theme.spacing(4),
|
paddingBottom: theme.spacing(4),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pageName = 'Impact Metrics';
|
|
||||||
|
|
||||||
export const ImpactMetricsPage: FC = () => (
|
export const ImpactMetricsPage: FC = () => (
|
||||||
<StyledWrapper>
|
<StyledWrapper>
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<PageHeader
|
|
||||||
title={pageName}
|
|
||||||
titleElement={
|
|
||||||
<Typography variant='h1' component='span'>
|
|
||||||
{pageName}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ImpactMetrics />
|
<ImpactMetrics />
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
</StyledWrapper>
|
</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[];
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user