1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

url handling

This commit is contained in:
Tymoteusz Czech 2025-06-30 17:13:54 +02:00
parent ed73f76092
commit 6f5908766e
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
6 changed files with 766 additions and 186 deletions

View 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>
);
};

View 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>
);
};

View File

@ -1,64 +1,25 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Box, Typography, Alert } from '@mui/material';
import {
LineChart,
NotEnoughData,
} from '../insights/components/LineChart/LineChart.tsx';
import {
StyledChartContainer,
StyledWidget,
StyledWidgetStats,
} from 'component/insights/InsightsCharts.styles';
import { Box, Typography, Button } from '@mui/material';
import { Add } from '@mui/icons-material';
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../insights/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';
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 [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 [modalOpen, setModalOpen] = useState(false);
const [editingChart, setEditingChart] = useState<ChartConfig | undefined>();
const handleSeriesChange = (series: string) => {
setSelectedSeries(series);
setSelectedLabels({}); // labels are series-specific
};
const { charts, addChart, updateChart, deleteChart } = useUrlState();
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) {
@ -70,138 +31,104 @@ export const ImpactMetrics: FC = () => {
}));
}, [metadata]);
const data = useChartData(timeSeriesData);
const handleAddChart = () => {
setEditingChart(undefined);
setModalOpen(true);
};
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 handleEditChart = (config: ChartConfig) => {
setEditingChart(config);
setModalOpen(true);
};
const minTime = start
? fromUnixTime(Number.parseInt(start, 10))
: undefined;
const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
const handleSaveChart = (config: Omit<ChartConfig, 'id'>) => {
if (editingChart) {
updateChart(editingChart.id, config);
} else {
addChart(config);
}
setModalOpen(false);
};
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;
const hasError = metadataError;
return (
<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
<>
<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>
) : null}
</Box>
</StyledWidgetStats>
<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}
/>
))
)}
<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}
<ChartConfigModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveChart}
initialConfig={editingChart}
metricSeries={metricSeries}
loading={metadataLoading}
/>
</StyledChartContainer>
</StyledWidget>
</Box>
</>
);
};

View File

@ -1,7 +1,6 @@
import type { FC } from 'react';
import { styled, Typography } from '@mui/material';
import { styled } from '@mui/material';
import { ImpactMetrics } from './ImpactMetrics.tsx';
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx';
const StyledWrapper = styled('div')(({ theme }) => ({
paddingTop: theme.spacing(2),
@ -14,19 +13,9 @@ const StyledContainer = styled('div')(({ theme }) => ({
paddingBottom: theme.spacing(4),
}));
const pageName = 'Impact Metrics';
export const ImpactMetricsPage: FC = () => (
<StyledWrapper>
<StyledContainer>
<PageHeader
title={pageName}
titleElement={
<Typography variant='h1' component='span'>
{pageName}
</Typography>
}
/>
<ImpactMetrics />
</StyledContainer>
</StyledWrapper>

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

View 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[];
};