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:
parent
cc834f73ef
commit
ed73f76092
@ -57,6 +57,11 @@ const BreadcrumbNav = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (location.pathname === '/impact-metrics') {
|
||||
// Hide breadcrumb on Impact Metrics page
|
||||
return null;
|
||||
}
|
||||
|
||||
if (paths.length === 1 && paths[0] === 'projects-archive') {
|
||||
// It's not possible to use `projects/archive`, because it's :projectId path
|
||||
paths = ['projects', 'archive'];
|
||||
|
207
frontend/src/component/impact-metrics/ImpactMetrics.tsx
Normal file
207
frontend/src/component/impact-metrics/ImpactMetrics.tsx
Normal 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>
|
||||
);
|
||||
};
|
33
frontend/src/component/impact-metrics/ImpactMetricsPage.tsx
Normal file
33
frontend/src/component/impact-metrics/ImpactMetricsPage.tsx
Normal 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>
|
||||
);
|
@ -7,27 +7,21 @@ import { StyledContainer } from './InsightsCharts.styles.ts';
|
||||
import { LifecycleInsights } from './sections/LifecycleInsights.tsx';
|
||||
import { PerformanceInsights } from './sections/PerformanceInsights.tsx';
|
||||
import { UserInsights } from './sections/UserInsights.tsx';
|
||||
import { ImpactMetrics } from './impact-metrics/ImpactMetrics.tsx';
|
||||
|
||||
const StyledWrapper = styled('div')(({ theme }) => ({
|
||||
paddingTop: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const NewInsights: FC = () => {
|
||||
const impactMetricsEnabled = useUiFlag('impactMetrics');
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<InsightsHeader />
|
||||
<StyledContainer>
|
||||
{impactMetricsEnabled ? <ImpactMetrics /> : null}
|
||||
<LifecycleInsights />
|
||||
<PerformanceInsights />
|
||||
<UserInsights />
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
const NewInsights: FC = () => (
|
||||
<StyledWrapper>
|
||||
<InsightsHeader />
|
||||
<StyledContainer>
|
||||
<LifecycleInsights />
|
||||
<PerformanceInsights />
|
||||
<UserInsights />
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
export const Insights: FC<{ withCharts?: boolean }> = (props) => {
|
||||
const useNewInsights = useUiFlag('lifecycleMetrics');
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -15,6 +15,7 @@ import GroupsIcon from '@mui/icons-material/GroupsOutlined';
|
||||
import RoleIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import InsightsIcon from '@mui/icons-material/Insights';
|
||||
import ImpactMetricsIcon from '@mui/icons-material/TrendingUpOutlined';
|
||||
import ApiAccessIcon from '@mui/icons-material/KeyOutlined';
|
||||
import SingleSignOnIcon from '@mui/icons-material/AssignmentOutlined';
|
||||
import NetworkIcon from '@mui/icons-material/HubOutlined';
|
||||
@ -44,6 +45,7 @@ const icons: Record<
|
||||
> = {
|
||||
'/search': FlagOutlinedIcon,
|
||||
'/insights': InsightsIcon,
|
||||
'/impact-metrics': ImpactMetricsIcon,
|
||||
'/applications': ApplicationsIcon,
|
||||
'/context': ContextFieldsIcon,
|
||||
'/feature-toggle-type': FlagTypesIcon,
|
||||
|
@ -12,6 +12,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { useNewAdminMenu } from 'hooks/useNewAdminMenu';
|
||||
import { AdminMenuNavigation } from '../AdminMenu/AdminNavigationItems.tsx';
|
||||
import { ConfigurationAccordion } from './ConfigurationAccordion.tsx';
|
||||
import { useRoutes } from './useRoutes.ts';
|
||||
|
||||
export const OtherLinksList = () => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
@ -38,6 +39,7 @@ export const PrimaryNavigationList: FC<{
|
||||
onClick: (activeItem: string) => void;
|
||||
activeItem?: string;
|
||||
}> = ({ mode, setMode, onClick, activeItem }) => {
|
||||
const { routes } = useRoutes();
|
||||
const PrimaryListItem = ({
|
||||
href,
|
||||
text,
|
||||
@ -52,17 +54,15 @@ export const PrimaryNavigationList: FC<{
|
||||
/>
|
||||
);
|
||||
|
||||
const { isOss } = useUiConfig();
|
||||
|
||||
return (
|
||||
<List>
|
||||
<PrimaryListItem href='/personal' text='Dashboard' />
|
||||
<PrimaryListItem href='/projects' text='Projects' />
|
||||
<PrimaryListItem href='/search' text='Flags overview' />
|
||||
<PrimaryListItem href='/playground' text='Playground' />
|
||||
{!isOss() ? (
|
||||
<PrimaryListItem href='/insights' text='Analytics' />
|
||||
) : null}
|
||||
{routes.primaryRoutes.map((route) => (
|
||||
<PrimaryListItem
|
||||
key={route.path}
|
||||
href={route.path}
|
||||
text={route.title}
|
||||
/>
|
||||
))}
|
||||
<ConfigurationAccordion
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
|
@ -135,6 +135,17 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Analytics",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"enterprise": true,
|
||||
"flag": "impactMetrics",
|
||||
"menu": {
|
||||
"primary": true,
|
||||
},
|
||||
"path": "/impact-metrics",
|
||||
"title": "Impact metrics",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"menu": {},
|
||||
|
@ -42,6 +42,7 @@ import { ViewIntegration } from 'component/integrations/ViewIntegration/ViewInte
|
||||
import { PaginatedApplicationList } from '../application/ApplicationList/PaginatedApplicationList.jsx';
|
||||
import { AddonRedirect } from 'component/integrations/AddonRedirect/AddonRedirect';
|
||||
import { Insights } from '../insights/Insights.jsx';
|
||||
import { ImpactMetricsPage } from '../impact-metrics/ImpactMetricsPage.tsx';
|
||||
import { FeedbackList } from '../feedbackNew/FeedbackList.jsx';
|
||||
import { Application } from 'component/application/Application';
|
||||
import { Signals } from 'component/signals/Signals';
|
||||
@ -159,6 +160,17 @@ export const routes: IRoute[] = [
|
||||
enterprise: true,
|
||||
},
|
||||
|
||||
// Impact Metrics
|
||||
{
|
||||
path: '/impact-metrics',
|
||||
title: 'Impact metrics',
|
||||
component: ImpactMetricsPage,
|
||||
type: 'protected',
|
||||
menu: { primary: true },
|
||||
enterprise: true,
|
||||
flag: 'impactMetrics',
|
||||
},
|
||||
|
||||
// Applications
|
||||
{
|
||||
path: '/applications/:name/*',
|
||||
|
Loading…
Reference in New Issue
Block a user