1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

feat: add lifecycle trend graphs (#10077)

Adds lifecycle trend graphs to the insights page.

The graphs are each placed within their own boxes. The boxes do not have
any more information in them yet.

Also, because the data returned from the API is still all zeroes, I've
used mock data that matches the sketches.

Finally, the chart configuration and how it's split into a
LifecycleChart that lazy loads a LifecycleChartComponent is based on the
LineChart and LineChartComponent that we use elsewhere on the insights
page.

Light mode:

<img width="1562" alt="image"
src="https://github.com/user-attachments/assets/6dd11168-be24-42d4-aa97-a7a55651fa0e"
/>

We might want to tweak some colors in dark mode, but maybe not? 🤷🏼 

![image](https://github.com/user-attachments/assets/9647e6b8-d8ea-4eb5-b9fd-6f4a24692476)
This commit is contained in:
Thomas Heartman 2025-06-05 08:35:14 +02:00 committed by GitHub
parent ae47771290
commit 16df33b078
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 336 additions and 1 deletions

View File

@ -159,6 +159,7 @@
}, },
"packageManager": "yarn@4.9.1", "packageManager": "yarn@4.9.1",
"dependencies": { "dependencies": {
"chartjs-plugin-datalabels": "^2.2.0",
"json-2-csv": "^5.5.5" "json-2-csv": "^5.5.5"
} }
} }

View File

@ -0,0 +1,5 @@
import { lazy } from 'react';
export const LifecycleChart = lazy(
() => import('./LifecycleChartComponent.tsx'),
);

View File

@ -0,0 +1,121 @@
import { type FC, useMemo } from 'react';
import {
CategoryScale,
LinearScale,
PointElement,
Tooltip,
Legend,
TimeScale,
Chart,
Filler,
type ChartData,
type ChartOptions,
BarElement,
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
import 'chartjs-adapter-date-fns';
import { type Theme, useTheme } from '@mui/material';
import { useLocationSettings } from 'hooks/useLocationSettings';
import merge from 'deepmerge';
import ChartDataLabels from 'chartjs-plugin-datalabels';
export const createOptions = (theme: Theme): ChartOptions<'bar'> => {
const fontSize = 10;
return {
plugins: {
legend: {
position: 'right',
maxWidth: 150,
align: 'start',
labels: {
color: theme.palette.text.secondary,
usePointStyle: true,
padding: 21,
boxHeight: 8,
font: {
size: fontSize,
},
},
},
datalabels: {
color: theme.palette.text.primary,
font: {
weight: 'bold',
size: fontSize,
},
anchor: 'end',
align: 'top',
offset: -6,
},
},
aspectRatio: 2 / 1,
responsive: true,
color: theme.palette.text.secondary,
scales: {
y: {
beginAtZero: true,
grid: {
color: theme.palette.divider,
borderColor: theme.palette.divider,
drawBorder: false,
},
ticks: {
stepSize: 1,
color: theme.palette.text.disabled,
font: {
size: fontSize,
},
},
},
x: {
grid: {
display: false,
},
ticks: {
color: theme.palette.text.primary,
font: {
size: fontSize,
},
},
},
},
} as const;
};
function mergeAll<T>(objects: Partial<T>[]): T {
return merge.all<T>(objects.filter((i) => i));
}
const LifecycleChartComponent: FC<{
data: ChartData<'bar', unknown>;
overrideOptions?: ChartOptions<'bar'>;
}> = ({ data, overrideOptions }) => {
const theme = useTheme();
const { locationSettings } = useLocationSettings();
const options = useMemo(
() => mergeAll([createOptions(theme)]),
[theme, locationSettings, overrideOptions],
);
return (
<>
<Bar options={options} data={data} plugins={[ChartDataLabels]} />
{/* todo: implement fallback for screen readers */}
</>
);
};
Chart.register(
CategoryScale,
LinearScale,
PointElement,
BarElement,
TimeScale,
Tooltip,
Legend,
Filler,
);
// for lazy-loading
export default LifecycleChartComponent;

View File

@ -3,13 +3,73 @@ import type { FC } from 'react';
import { FilterItemParam } from 'utils/serializeQueryParams'; import { FilterItemParam } from 'utils/serializeQueryParams';
import { InsightsSection } from 'component/insights/sections/InsightsSection'; import { InsightsSection } from 'component/insights/sections/InsightsSection';
import { InsightsFilters } from 'component/insights/InsightsFilters'; import { InsightsFilters } from 'component/insights/InsightsFilters';
import { allOption } from 'component/common/ProjectSelect/ProjectSelect';
import { useInsights } from 'hooks/api/getters/useInsights/useInsights';
import { LifecycleChart } from '../components/LifecycleChart/LifecycleChart.tsx';
import { styled, useTheme } from '@mui/material';
type LifecycleTrend = {
totalFlags: number;
averageTimeInStageDays: number;
categories: {
experimental: {
flagsOlderThanWeek: number;
newFlagsThisWeek: number;
};
release: {
flagsOlderThanWeek: number;
newFlagsThisWeek: number;
};
permanent: {
flagsOlderThanWeek: number;
newFlagsThisWeek: number;
};
};
};
type LifecycleInsights = {
develop: LifecycleTrend;
production: LifecycleTrend;
cleanup: LifecycleTrend;
};
const useChartColors = () => {
const theme = useTheme();
return {
olderThanWeek: theme.palette.primary.light,
newThisWeek: theme.palette.success.border,
};
};
const ChartRow = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: theme.spacing(2),
}));
const ChartContainer = styled('article')(({ theme }) => ({
background: theme.palette.background.default,
borderRadius: theme.shape.borderRadiusLarge,
padding: theme.spacing(2),
minWidth: 0,
}));
export const LifecycleInsights: FC = () => { export const LifecycleInsights: FC = () => {
const statePrefix = 'lifecycle-'; const statePrefix = 'lifecycle-';
const stateConfig = { const stateConfig = {
[`${statePrefix}project`]: FilterItemParam, [`${statePrefix}project`]: FilterItemParam,
}; };
const [state, setState] = usePersistentTableState('insights', stateConfig); const [state, setState] = usePersistentTableState(
'insights-lifecycle',
stateConfig,
);
// todo: use data from the actual endpoint when we have something useful to return
const projects = state[`${statePrefix}project`]?.values ?? [allOption.id];
const { insights, loading } = useInsights();
// @ts-expect-error (lifecycleMetrics): The schema hasn't been updated yet.
const { lifecycleTrends } = insights;
return ( return (
<InsightsSection <InsightsSection
@ -21,6 +81,144 @@ export const LifecycleInsights: FC = () => {
filterNamePrefix={statePrefix} filterNamePrefix={statePrefix}
/> />
} }
>
<ChartRow>
{Object.entries(mockData).map(([stage, data]) => {
return (
<ChartContainer key={stage}>
<Chart data={data} />
</ChartContainer>
);
})}
</ChartRow>
</InsightsSection>
);
};
const Chart: React.FC<{ data: LifecycleTrend }> = ({ data }) => {
const chartColors = useChartColors();
const oldData = [
data.categories.experimental.flagsOlderThanWeek,
data.categories.release.flagsOlderThanWeek,
data.categories.permanent.flagsOlderThanWeek,
];
return (
<LifecycleChart
data={{
labels: [`Experimental`, `Release`, `Other flags`],
datasets: [
{
label: 'Flags > 1 week old',
data: oldData,
stack: '1',
backgroundColor: chartColors.olderThanWeek,
borderRadius: 4,
datalabels: {
labels: {
value: {
formatter: (value, context) => {
// todo (lifecycleMetrics): use a nice
// formatter here, so that 1,000,000
// flags are instead formatted as 1M
if (
context.chart.legend
?.legendItems?.[1].hidden
) {
return value;
}
return '';
},
},
},
},
},
{
label: 'New flags this week',
data: [
data.categories.experimental.newFlagsThisWeek,
data.categories.release.newFlagsThisWeek,
data.categories.permanent.newFlagsThisWeek,
],
stack: '1',
backgroundColor: chartColors.newThisWeek,
borderRadius: 4,
datalabels: {
labels: {
value: {
formatter: (value, context) => {
if (
context.chart.legend
?.legendItems?.[0].hidden
) {
return value;
}
return (
value + oldData[context.dataIndex]
);
},
},
},
},
},
],
}}
/> />
); );
}; };
const mockData: LifecycleInsights = {
develop: {
totalFlags: 35,
averageTimeInStageDays: 28,
categories: {
experimental: {
flagsOlderThanWeek: 11,
newFlagsThisWeek: 4,
},
release: {
flagsOlderThanWeek: 12,
newFlagsThisWeek: 1,
},
permanent: {
flagsOlderThanWeek: 7,
newFlagsThisWeek: 0,
},
},
},
production: {
totalFlags: 10,
averageTimeInStageDays: 14,
categories: {
experimental: {
flagsOlderThanWeek: 2,
newFlagsThisWeek: 3,
},
release: {
flagsOlderThanWeek: 1,
newFlagsThisWeek: 1,
},
permanent: {
flagsOlderThanWeek: 3,
newFlagsThisWeek: 0,
},
},
},
cleanup: {
totalFlags: 5,
averageTimeInStageDays: 16,
categories: {
experimental: {
flagsOlderThanWeek: 0,
newFlagsThisWeek: 3,
},
release: {
flagsOlderThanWeek: 0,
newFlagsThisWeek: 1,
},
permanent: {
flagsOlderThanWeek: 1,
newFlagsThisWeek: 0,
},
},
},
};

View File

@ -4106,6 +4106,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"chartjs-plugin-datalabels@npm:^2.2.0":
version: 2.2.0
resolution: "chartjs-plugin-datalabels@npm:2.2.0"
peerDependencies:
chart.js: ">=3.0.0"
checksum: 10c0/de4855a795e4eef34869a16db1a8a0f905b6dfed0258c733338f472625361eb56fb899214b18651c1c1064cd343a78285ba576576693a40ec51285a84f022ea0
languageName: node
linkType: hard
"check-error@npm:^2.1.1": "check-error@npm:^2.1.1":
version: 2.1.1 version: 2.1.1
resolution: "check-error@npm:2.1.1" resolution: "check-error@npm:2.1.1"
@ -10138,6 +10147,7 @@ __metadata:
chart.js: "npm:3.9.1" chart.js: "npm:3.9.1"
chartjs-adapter-date-fns: "npm:3.0.0" chartjs-adapter-date-fns: "npm:3.0.0"
chartjs-plugin-annotation: "npm:2.2.1" chartjs-plugin-annotation: "npm:2.2.1"
chartjs-plugin-datalabels: "npm:^2.2.0"
classnames: "npm:2.5.1" classnames: "npm:2.5.1"
copy-to-clipboard: "npm:3.3.3" copy-to-clipboard: "npm:3.3.3"
countries-and-timezones: "npm:^3.4.0" countries-and-timezones: "npm:^3.4.0"