mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-07 01:16:28 +02:00
account for multi-series charts
This commit is contained in:
parent
b07d013d50
commit
bee42187b9
@ -15,16 +15,30 @@ import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMeta
|
|||||||
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
|
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
|
||||||
import { usePlaceholderData } from '../hooks/usePlaceholderData.js';
|
import { usePlaceholderData } from '../hooks/usePlaceholderData.js';
|
||||||
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
|
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
|
||||||
import { getDisplayFormat, getTimeUnit } from './time-utils.ts';
|
import {
|
||||||
|
getDisplayFormat,
|
||||||
|
getSeriesLabel,
|
||||||
|
getTimeUnit,
|
||||||
|
} from './impact-metrics-utils.ts';
|
||||||
import { fromUnixTime } from 'date-fns';
|
import { fromUnixTime } from 'date-fns';
|
||||||
|
import { useSeriesColor } from './hooks/useSeriesColor.ts';
|
||||||
|
|
||||||
export const ImpactMetrics: FC = () => {
|
export const ImpactMetrics: FC = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const getSeriesColor = useSeriesColor();
|
||||||
const [selectedSeries, setSelectedSeries] = useState<string>('');
|
const [selectedSeries, setSelectedSeries] = useState<string>('');
|
||||||
const [selectedRange, setSelectedRange] = useState<
|
const [selectedRange, setSelectedRange] = useState<
|
||||||
'hour' | 'day' | 'week' | 'month'
|
'hour' | 'day' | 'week' | 'month'
|
||||||
>('day');
|
>('day');
|
||||||
const [beginAtZero, setBeginAtZero] = useState(false);
|
const [beginAtZero, setBeginAtZero] = useState(false);
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<
|
||||||
|
Record<string, string[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const handleSeriesChange = (series: string) => {
|
||||||
|
setSelectedSeries(series);
|
||||||
|
setSelectedLabels({}); // labels are series-specific
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
metadata,
|
metadata,
|
||||||
@ -32,12 +46,19 @@ export const ImpactMetrics: FC = () => {
|
|||||||
error: metadataError,
|
error: metadataError,
|
||||||
} = useImpactMetricsMetadata();
|
} = useImpactMetricsMetadata();
|
||||||
const {
|
const {
|
||||||
data: { start, end, data: timeSeriesData },
|
data: { start, end, series: timeSeriesData, labels: availableLabels },
|
||||||
loading: dataLoading,
|
loading: dataLoading,
|
||||||
error: dataError,
|
error: dataError,
|
||||||
} = useImpactMetricsData(
|
} = useImpactMetricsData(
|
||||||
selectedSeries
|
selectedSeries
|
||||||
? { series: selectedSeries, range: selectedRange }
|
? {
|
||||||
|
series: selectedSeries,
|
||||||
|
range: selectedRange,
|
||||||
|
labels:
|
||||||
|
Object.keys(selectedLabels).length > 0
|
||||||
|
? selectedLabels
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -57,7 +78,7 @@ export const ImpactMetrics: FC = () => {
|
|||||||
}, [metadata]);
|
}, [metadata]);
|
||||||
|
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
if (!timeSeriesData.length) {
|
if (!timeSeriesData || timeSeriesData.length === 0) {
|
||||||
return {
|
return {
|
||||||
labels: [],
|
labels: [],
|
||||||
datasets: [
|
datasets: [
|
||||||
@ -70,29 +91,75 @@ export const ImpactMetrics: FC = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamps = timeSeriesData.map(
|
if (timeSeriesData.length === 1) {
|
||||||
([epochTimestamp]) => new Date(epochTimestamp * 1000),
|
const series = timeSeriesData[0];
|
||||||
);
|
const timestamps = series.data.map(
|
||||||
const values = timeSeriesData.map(([, value]) => value);
|
([epochTimestamp]) => new Date(epochTimestamp * 1000),
|
||||||
|
);
|
||||||
|
const values = series.data.map(([, value]) => value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: timestamps,
|
labels: timestamps,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: values,
|
data: values,
|
||||||
borderColor: theme.palette.primary.main,
|
borderColor: theme.palette.primary.main,
|
||||||
backgroundColor: theme.palette.primary.light,
|
backgroundColor: theme.palette.primary.light,
|
||||||
},
|
label: getSeriesLabel(series.metric),
|
||||||
],
|
},
|
||||||
};
|
],
|
||||||
}, [timeSeriesData, theme]);
|
};
|
||||||
|
} else {
|
||||||
|
const allTimestamps = new Set<number>();
|
||||||
|
timeSeriesData.forEach((series) => {
|
||||||
|
series.data.forEach(([timestamp]) => {
|
||||||
|
allTimestamps.add(timestamp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const sortedTimestamps = Array.from(allTimestamps).sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
);
|
||||||
|
const labels = sortedTimestamps.map(
|
||||||
|
(timestamp) => new Date(timestamp * 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
const datasets = timeSeriesData.map((series) => {
|
||||||
|
const seriesLabel = getSeriesLabel(series.metric);
|
||||||
|
const color = getSeriesColor(seriesLabel);
|
||||||
|
|
||||||
|
const dataMap = new Map(series.data);
|
||||||
|
|
||||||
|
const data = sortedTimestamps.map(
|
||||||
|
(timestamp) => dataMap.get(timestamp) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: seriesLabel,
|
||||||
|
data,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color,
|
||||||
|
fill: false,
|
||||||
|
spanGaps: false, // Don't connect nulls
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [timeSeriesData, theme, getSeriesColor]);
|
||||||
|
|
||||||
const hasError = metadataError || dataError;
|
const hasError = metadataError || dataError;
|
||||||
const isLoading = metadataLoading || dataLoading;
|
const isLoading = metadataLoading || dataLoading;
|
||||||
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
|
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
|
||||||
const notEnoughData = useMemo(
|
const notEnoughData = useMemo(
|
||||||
() => !isLoading && !data.datasets.some((d) => d.data.length > 1),
|
() =>
|
||||||
[data, isLoading],
|
!isLoading &&
|
||||||
|
(!timeSeriesData ||
|
||||||
|
timeSeriesData.length === 0 ||
|
||||||
|
!data.datasets.some((d) => d.data.length > 1)),
|
||||||
|
[data, isLoading, timeSeriesData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const minTime = start
|
const minTime = start
|
||||||
@ -124,13 +191,16 @@ export const ImpactMetrics: FC = () => {
|
|||||||
>
|
>
|
||||||
<ImpactMetricsControls
|
<ImpactMetricsControls
|
||||||
selectedSeries={selectedSeries}
|
selectedSeries={selectedSeries}
|
||||||
onSeriesChange={setSelectedSeries}
|
onSeriesChange={handleSeriesChange}
|
||||||
selectedRange={selectedRange}
|
selectedRange={selectedRange}
|
||||||
onRangeChange={setSelectedRange}
|
onRangeChange={setSelectedRange}
|
||||||
beginAtZero={beginAtZero}
|
beginAtZero={beginAtZero}
|
||||||
onBeginAtZeroChange={setBeginAtZero}
|
onBeginAtZeroChange={setBeginAtZero}
|
||||||
metricSeries={metricSeries}
|
metricSeries={metricSeries}
|
||||||
loading={metadataLoading}
|
loading={metadataLoading}
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
onLabelsChange={setSelectedLabels}
|
||||||
|
availableLabels={availableLabels}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!selectedSeries && !isLoading ? (
|
{!selectedSeries && !isLoading ? (
|
||||||
@ -189,7 +259,15 @@ export const ImpactMetrics: FC = () => {
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: false,
|
display:
|
||||||
|
timeSeriesData &&
|
||||||
|
timeSeriesData.length > 1,
|
||||||
|
position: 'bottom' as const,
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
boxWidth: 8,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animations: {
|
animations: {
|
||||||
|
@ -10,8 +10,10 @@ import {
|
|||||||
Autocomplete,
|
Autocomplete,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
|
Chip,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
|
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';
|
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||||
|
|
||||||
export interface ImpactMetricsControlsProps {
|
export interface ImpactMetricsControlsProps {
|
||||||
@ -23,6 +25,9 @@ export interface ImpactMetricsControlsProps {
|
|||||||
onBeginAtZeroChange: (beginAtZero: boolean) => void;
|
onBeginAtZeroChange: (beginAtZero: boolean) => void;
|
||||||
metricSeries: (ImpactMetricsSeries & { name: string })[];
|
metricSeries: (ImpactMetricsSeries & { name: string })[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
selectedLabels: Record<string, string[]>;
|
||||||
|
onLabelsChange: (labels: Record<string, string[]>) => void;
|
||||||
|
availableLabels?: ImpactMetricsLabels;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
|
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
|
||||||
@ -34,86 +39,166 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
|
|||||||
onBeginAtZeroChange,
|
onBeginAtZeroChange,
|
||||||
metricSeries,
|
metricSeries,
|
||||||
loading = false,
|
loading = false,
|
||||||
}) => (
|
selectedLabels,
|
||||||
<Box
|
onLabelsChange,
|
||||||
sx={(theme) => ({
|
availableLabels,
|
||||||
display: 'flex',
|
}) => {
|
||||||
flexDirection: 'column',
|
const handleLabelChange = (labelKey: string, values: string[]) => {
|
||||||
gap: theme.spacing(3),
|
const newLabels = { ...selectedLabels };
|
||||||
maxWidth: 400,
|
if (values.length === 0) {
|
||||||
})}
|
delete newLabels[labelKey];
|
||||||
>
|
} else {
|
||||||
<Typography variant='body2' color='text.secondary'>
|
newLabels[labelKey] = values;
|
||||||
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
|
onLabelsChange(newLabels);
|
||||||
as system performance, usage patterns or error rates.
|
};
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Autocomplete
|
const clearAllLabels = () => {
|
||||||
options={metricSeries}
|
onLabelsChange({});
|
||||||
getOptionLabel={(option) => option.name}
|
};
|
||||||
value={
|
|
||||||
metricSeries.find((option) => option.name === selectedSeries) ||
|
return (
|
||||||
null
|
<Box
|
||||||
}
|
sx={(theme) => ({
|
||||||
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
|
display: 'flex',
|
||||||
disabled={loading}
|
flexDirection: 'column',
|
||||||
renderOption={(props, option, { inputValue }) => (
|
gap: theme.spacing(3),
|
||||||
<Box component='li' {...props}>
|
maxWidth: 400,
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
})}
|
||||||
<Typography variant='body2'>
|
>
|
||||||
<Highlighter search={inputValue}>
|
<Typography variant='body2' color='text.secondary'>
|
||||||
{option.name}
|
Select a custom metric to see its value over time. This can help
|
||||||
</Highlighter>
|
you understand the impact of your feature rollout on key
|
||||||
</Typography>
|
outcomes, such as system performance, usage patterns or error
|
||||||
<Typography variant='caption' color='text.secondary'>
|
rates.
|
||||||
<Highlighter search={inputValue}>
|
</Typography>
|
||||||
{option.help}
|
|
||||||
</Highlighter>
|
<Autocomplete
|
||||||
</Typography>
|
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>
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
renderInput={(params) => (
|
</Box>
|
||||||
<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'
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
import { useTheme } from '@mui/material';
|
||||||
|
|
||||||
|
export const useSeriesColor = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const colors = theme.palette.charts.series;
|
||||||
|
|
||||||
|
return (seriesLabel: string): string => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seriesLabel.length; i++) {
|
||||||
|
const char = seriesLabel.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash = hash & hash; // Convert to 32-bit integer
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash) % colors.length;
|
||||||
|
return colors[index];
|
||||||
|
};
|
||||||
|
};
|
@ -25,3 +25,26 @@ export const getDisplayFormat = (selectedRange: string) => {
|
|||||||
return 'MMM dd HH:mm';
|
return 'MMM dd HH:mm';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getSeriesLabel = (metric: Record<string, string>): string => {
|
||||||
|
const { __name__, ...labels } = metric;
|
||||||
|
|
||||||
|
const labelParts = Object.entries(labels)
|
||||||
|
.filter(([key, value]) => key !== '__name__' && value)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
if (!__name__ && !labelParts) {
|
||||||
|
return 'Series';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!__name__) {
|
||||||
|
return labelParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!labelParts) {
|
||||||
|
return __name__;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${__name__} (${labelParts})`;
|
||||||
|
};
|
@ -3,9 +3,25 @@ import { formatApiPath } from 'utils/formatPath';
|
|||||||
|
|
||||||
export type TimeSeriesData = [number, number][];
|
export type TimeSeriesData = [number, number][];
|
||||||
|
|
||||||
|
export type ImpactMetricsLabels = Record<string, string[]>;
|
||||||
|
|
||||||
|
export type ImpactMetricsSeries = {
|
||||||
|
metric: Record<string, string>;
|
||||||
|
data: TimeSeriesData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImpactMetricsResponse = {
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
step?: string;
|
||||||
|
series: ImpactMetricsSeries[];
|
||||||
|
labels?: ImpactMetricsLabels;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImpactMetricsQuery = {
|
export type ImpactMetricsQuery = {
|
||||||
series: string;
|
series: string;
|
||||||
range: 'hour' | 'day' | 'week' | 'month';
|
range: 'hour' | 'day' | 'week' | 'month';
|
||||||
|
labels?: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
|
export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
|
||||||
@ -17,30 +33,45 @@ export const useImpactMetricsData = (query?: ImpactMetricsQuery) => {
|
|||||||
series: query.series,
|
series: query.series,
|
||||||
range: query.range,
|
range: query.range,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (query.labels && Object.keys(query.labels).length > 0) {
|
||||||
|
// Send labels as they are - the backend will handle the formatting
|
||||||
|
const labelsParam = Object.entries(query.labels).reduce(
|
||||||
|
(acc, [key, values]) => {
|
||||||
|
if (values.length > 0) {
|
||||||
|
acc[key] = values;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(labelsParam).length > 0) {
|
||||||
|
params.append('labels', JSON.stringify(labelsParam));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return `api/admin/impact-metrics/?${params.toString()}`;
|
return `api/admin/impact-metrics/?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PATH = createPath();
|
const PATH = createPath();
|
||||||
|
|
||||||
const { data, refetch, loading, error } = useApiGetter<{
|
const { data, refetch, loading, error } =
|
||||||
start?: string;
|
useApiGetter<ImpactMetricsResponse>(
|
||||||
end?: string;
|
shouldFetch ? formatApiPath(PATH) : null,
|
||||||
step?: string;
|
shouldFetch
|
||||||
data: TimeSeriesData;
|
? () => fetcher(formatApiPath(PATH), 'Impact metrics data')
|
||||||
}>(
|
: () => Promise.resolve([]),
|
||||||
shouldFetch ? formatApiPath(PATH) : null,
|
{
|
||||||
shouldFetch
|
refreshInterval: 30 * 1_000,
|
||||||
? () => fetcher(formatApiPath(PATH), 'Impact metrics data')
|
revalidateOnFocus: true,
|
||||||
: () => Promise.resolve([]),
|
},
|
||||||
{
|
);
|
||||||
refreshInterval: 30 * 1_000,
|
|
||||||
revalidateOnFocus: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: data || {
|
data: data || {
|
||||||
data: [],
|
series: [],
|
||||||
|
labels: {},
|
||||||
},
|
},
|
||||||
refetch,
|
refetch,
|
||||||
loading: shouldFetch ? loading : false,
|
loading: shouldFetch ? loading : false,
|
||||||
|
Loading…
Reference in New Issue
Block a user