1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-27 13:49:10 +02:00

refactor: move impact metrics to a separate page

This commit is contained in:
Tymoteusz Czech 2025-06-30 13:49:48 +02:00
parent cc834f73ef
commit ed73f76092
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
13 changed files with 289 additions and 241 deletions

View File

@ -57,6 +57,11 @@ const BreadcrumbNav = () => {
return null; return null;
} }
if (location.pathname === '/impact-metrics') {
// Hide breadcrumb on Impact Metrics page
return null;
}
if (paths.length === 1 && paths[0] === 'projects-archive') { if (paths.length === 1 && paths[0] === 'projects-archive') {
// It's not possible to use `projects/archive`, because it's :projectId path // It's not possible to use `projects/archive`, because it's :projectId path
paths = ['projects', 'archive']; paths = ['projects', 'archive'];

View File

@ -0,0 +1,207 @@
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 { 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';
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 (
<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>
);
};

View File

@ -0,0 +1,33 @@
import type { FC } from 'react';
import { styled, Typography } 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),
}));
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(4),
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

@ -7,27 +7,21 @@ import { StyledContainer } from './InsightsCharts.styles.ts';
import { LifecycleInsights } from './sections/LifecycleInsights.tsx'; import { LifecycleInsights } from './sections/LifecycleInsights.tsx';
import { PerformanceInsights } from './sections/PerformanceInsights.tsx'; import { PerformanceInsights } from './sections/PerformanceInsights.tsx';
import { UserInsights } from './sections/UserInsights.tsx'; import { UserInsights } from './sections/UserInsights.tsx';
import { ImpactMetrics } from './impact-metrics/ImpactMetrics.tsx';
const StyledWrapper = styled('div')(({ theme }) => ({ const StyledWrapper = styled('div')(({ theme }) => ({
paddingTop: theme.spacing(2), paddingTop: theme.spacing(2),
})); }));
const NewInsights: FC = () => { const NewInsights: FC = () => (
const impactMetricsEnabled = useUiFlag('impactMetrics');
return (
<StyledWrapper> <StyledWrapper>
<InsightsHeader /> <InsightsHeader />
<StyledContainer> <StyledContainer>
{impactMetricsEnabled ? <ImpactMetrics /> : null}
<LifecycleInsights /> <LifecycleInsights />
<PerformanceInsights /> <PerformanceInsights />
<UserInsights /> <UserInsights />
</StyledContainer> </StyledContainer>
</StyledWrapper> </StyledWrapper>
); );
};
export const Insights: FC<{ withCharts?: boolean }> = (props) => { export const Insights: FC<{ withCharts?: boolean }> = (props) => {
const useNewInsights = useUiFlag('lifecycleMetrics'); const useNewInsights = useUiFlag('lifecycleMetrics');

View File

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

View File

@ -15,6 +15,7 @@ import GroupsIcon from '@mui/icons-material/GroupsOutlined';
import RoleIcon from '@mui/icons-material/AdminPanelSettingsOutlined'; import RoleIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import InsightsIcon from '@mui/icons-material/Insights'; import InsightsIcon from '@mui/icons-material/Insights';
import ImpactMetricsIcon from '@mui/icons-material/TrendingUpOutlined';
import ApiAccessIcon from '@mui/icons-material/KeyOutlined'; import ApiAccessIcon from '@mui/icons-material/KeyOutlined';
import SingleSignOnIcon from '@mui/icons-material/AssignmentOutlined'; import SingleSignOnIcon from '@mui/icons-material/AssignmentOutlined';
import NetworkIcon from '@mui/icons-material/HubOutlined'; import NetworkIcon from '@mui/icons-material/HubOutlined';
@ -44,6 +45,7 @@ const icons: Record<
> = { > = {
'/search': FlagOutlinedIcon, '/search': FlagOutlinedIcon,
'/insights': InsightsIcon, '/insights': InsightsIcon,
'/impact-metrics': ImpactMetricsIcon,
'/applications': ApplicationsIcon, '/applications': ApplicationsIcon,
'/context': ContextFieldsIcon, '/context': ContextFieldsIcon,
'/feature-toggle-type': FlagTypesIcon, '/feature-toggle-type': FlagTypesIcon,

View File

@ -12,6 +12,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useNewAdminMenu } from 'hooks/useNewAdminMenu'; import { useNewAdminMenu } from 'hooks/useNewAdminMenu';
import { AdminMenuNavigation } from '../AdminMenu/AdminNavigationItems.tsx'; import { AdminMenuNavigation } from '../AdminMenu/AdminNavigationItems.tsx';
import { ConfigurationAccordion } from './ConfigurationAccordion.tsx'; import { ConfigurationAccordion } from './ConfigurationAccordion.tsx';
import { useRoutes } from './useRoutes.ts';
export const OtherLinksList = () => { export const OtherLinksList = () => {
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
@ -38,6 +39,7 @@ export const PrimaryNavigationList: FC<{
onClick: (activeItem: string) => void; onClick: (activeItem: string) => void;
activeItem?: string; activeItem?: string;
}> = ({ mode, setMode, onClick, activeItem }) => { }> = ({ mode, setMode, onClick, activeItem }) => {
const { routes } = useRoutes();
const PrimaryListItem = ({ const PrimaryListItem = ({
href, href,
text, text,
@ -52,17 +54,15 @@ export const PrimaryNavigationList: FC<{
/> />
); );
const { isOss } = useUiConfig();
return ( return (
<List> <List>
<PrimaryListItem href='/personal' text='Dashboard' /> {routes.primaryRoutes.map((route) => (
<PrimaryListItem href='/projects' text='Projects' /> <PrimaryListItem
<PrimaryListItem href='/search' text='Flags overview' /> key={route.path}
<PrimaryListItem href='/playground' text='Playground' /> href={route.path}
{!isOss() ? ( text={route.title}
<PrimaryListItem href='/insights' text='Analytics' /> />
) : null} ))}
<ConfigurationAccordion <ConfigurationAccordion
mode={mode} mode={mode}
setMode={setMode} setMode={setMode}

View File

@ -135,6 +135,17 @@ exports[`returns all baseRoutes 1`] = `
"title": "Analytics", "title": "Analytics",
"type": "protected", "type": "protected",
}, },
{
"component": [Function],
"enterprise": true,
"flag": "impactMetrics",
"menu": {
"primary": true,
},
"path": "/impact-metrics",
"title": "Impact metrics",
"type": "protected",
},
{ {
"component": [Function], "component": [Function],
"menu": {}, "menu": {},

View File

@ -42,6 +42,7 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte
import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx'; import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect'; import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
import { Insights } from '../insights/Insights.jsx'; import { Insights } from '../insights/Insights.jsx';
import { ImpactMetricsPage } from '../impact-metrics/ImpactMetricsPage.tsx';
import { FeedbackList } from '../feedbackNew/FeedbackList.jsx'; import { FeedbackList } from '../feedbackNew/FeedbackList.jsx';
import { Application } from 'component/application/Application'; import { Application } from 'component/application/Application';
import { Signals } from 'component/signals/Signals'; import { Signals } from 'component/signals/Signals';
@ -159,6 +160,17 @@ export const routes: IRoute[] = [
enterprise: true, enterprise: true,
}, },
// Impact Metrics
{
path: '/impact-metrics',
title: 'Impact metrics',
component: ImpactMetricsPage,
type: 'protected',
menu: { primary: true },
enterprise: true,
flag: 'impactMetrics',
},
// Applications // Applications
{ {
path: '/applications/:name/*', path: '/applications/:name/*',