1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

Merge branch 'main' into feat/simplify-imports

This commit is contained in:
Youssef Khedher 2022-02-22 00:23:56 +01:00 committed by GitHub
commit aeaea9602c
35 changed files with 934 additions and 511 deletions

View File

@ -129,7 +129,7 @@ describe('feature toggle', () => {
});
it('Can add a gradual rollout strategy to the development environment', () => {
cy.wait(500);
cy.wait(1000);
cy.visit(`/projects/default/features/${featureToggleName}/strategies`);
cy.get('[data-test=ADD_NEW_STRATEGY_ID]').click();
cy.get('[data-test=ADD_NEW_STRATEGY_CARD_BUTTON_ID-2').click();
@ -172,7 +172,7 @@ describe('feature toggle', () => {
});
it('can update a strategy in the development environment', () => {
cy.wait(500);
cy.wait(1000);
cy.visit(`/projects/default/features/${featureToggleName}/strategies`);
cy.get('[data-test=STRATEGY_ACCORDION_ID-flexibleRollout').click();
@ -219,7 +219,7 @@ describe('feature toggle', () => {
});
it('can delete a strategy in the development environment', () => {
cy.wait(500);
cy.wait(1000);
cy.visit(`/projects/default/features/${featureToggleName}/strategies`);
cy.intercept(
@ -238,7 +238,7 @@ describe('feature toggle', () => {
});
it('Can add a userid strategy to the development environment', () => {
cy.wait(500);
cy.wait(1000);
cy.visit(`/projects/default/features/${featureToggleName}/strategies`);
cy.get('[data-test=ADD_NEW_STRATEGY_ID]').click();
cy.get('[data-test=ADD_NEW_STRATEGY_CARD_BUTTON_ID-3').click();
@ -285,7 +285,7 @@ describe('feature toggle', () => {
it('Can add two variant to the feature', () => {
const variantName = 'my-new-variant';
const secondVariantName = 'my-second-variant';
cy.wait(500);
cy.wait(1000);
cy.visit(`/projects/default/features/${featureToggleName}/variants`);
cy.intercept(
'PATCH',
@ -315,7 +315,7 @@ describe('feature toggle', () => {
cy.wait('@variantcreation');
});
it('Can set weight to fixed value for one of the variants', () => {
cy.wait(500);
cy.wait(1000);
cy.visit(`/projects/default/features/${featureToggleName}/variants`);
cy.get('[data-test=VARIANT_EDIT_BUTTON]').first().click();
@ -352,7 +352,7 @@ describe('feature toggle', () => {
it(`can delete variant`, () => {
const variantName = 'to-be-deleted';
cy.wait(500);
cy.wait(1000);
cy.visit(`/projects/default/features/${featureToggleName}/variants`);
cy.get('[data-test=ADD_VARIANT_BUTTON]').click();
cy.get('[data-test=VARIANT_NAME_INPUT]').type(variantName);

View File

@ -1,7 +1,7 @@
{
"name": "unleash-frontend",
"description": "unleash your features",
"version": "4.8.0-beta.5",
"version": "4.8.0-beta.7",
"keywords": [
"unleash",
"feature toggle",
@ -48,6 +48,7 @@
"@types/debounce": "1.2.1",
"@types/deep-diff": "1.0.1",
"@types/jest": "27.4.0",
"@types/lodash.clonedeep": "4.5.6",
"@types/node": "14.18.12",
"@types/react": "17.0.39",
"@types/react-dom": "17.0.11",
@ -56,6 +57,8 @@
"@types/react-test-renderer": "17.0.1",
"@types/react-timeago": "4.1.3",
"@welldone-software/why-did-you-render": "6.2.3",
"chart.js": "3.7.1",
"chartjs-adapter-date-fns": "2.0.0",
"classnames": "2.3.1",
"copy-to-clipboard": "3.3.1",
"craco": "0.0.3",
@ -66,12 +69,12 @@
"deep-diff": "1.0.2",
"fast-json-patch": "3.1.0",
"http-proxy-middleware": "2.0.3",
"@types/lodash.clonedeep": "4.5.6",
"lodash.clonedeep": "4.5.0",
"lodash.flow": "3.5.0",
"prettier": "2.5.1",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-chartjs-2": "4.0.1",
"react-dnd": "14.0.5",
"react-dnd-html5-backend": "14.1.0",
"react-dom": "17.0.2",
@ -82,7 +85,7 @@
"react-test-renderer": "16.14.0",
"react-timeago": "6.2.1",
"sass": "1.49.8",
"swr": "1.2.1",
"swr": "1.2.2",
"typescript": "4.5.5",
"web-vitals": "2.1.4"
},

View File

@ -133,6 +133,10 @@ p {
font-size: var(--p-size);
}
[hidden] {
display: none !important;
}
@media screen and (max-width: 1024px) {
:root {
--drawer-padding: 0.75rem 1.25rem;

View File

@ -7,6 +7,7 @@ export interface ISelectOption {
title?: string;
label?: string;
}
export interface ISelectMenuProps {
name: string;
id: string;
@ -14,16 +15,19 @@ export interface ISelectMenuProps {
label?: string;
options: ISelectOption[];
style?: object;
onChange?: (
event: React.ChangeEvent<{ name?: string; value: unknown }>,
child: React.ReactNode
) => void;
onChange?: OnGeneralSelectChange;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
classes?: any;
defaultValue?: string;
}
export type OnGeneralSelectChange = (
event: React.ChangeEvent<{ name?: string; value: unknown }>,
child: React.ReactNode
) => void;
const GeneralSelect: React.FC<ISelectMenuProps> = ({
name,
value = '',
@ -35,6 +39,7 @@ const GeneralSelect: React.FC<ISelectMenuProps> = ({
disabled = false,
className,
classes,
fullWidth,
...rest
}) => {
const renderSelectItems = () =>
@ -50,10 +55,13 @@ const GeneralSelect: React.FC<ISelectMenuProps> = ({
));
return (
<FormControl variant="outlined" size="small" classes={classes}>
<InputLabel htmlFor={id} id={id}>
{label}
</InputLabel>
<FormControl
variant="outlined"
size="small"
classes={classes}
fullWidth={fullWidth}
>
<InputLabel htmlFor={id}>{label}</InputLabel>
<Select
defaultValue={defaultValue}
name={name}

View File

@ -3,7 +3,7 @@ import { useStyles } from './StatusChip.styles';
interface IStatusChip {
stale: boolean;
showActive: true;
showActive?: true;
}
const StatusChip = ({ stale, showActive = true }: IStatusChip) => {

View File

@ -16,6 +16,11 @@ const dateOptions = {
year: 'numeric',
};
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
};
export const filterByFlags = flags => r => {
if (r.flag && !flags[r.flag]) {
return false;
@ -41,6 +46,13 @@ export const formatDateWithLocale = (v, locale, tz) => {
return new Date(v).toLocaleString(locale, dateOptions);
};
export const formatTimeWithLocale = (v, locale, tz) => {
if (tz) {
dateTimeOptions.timeZone = tz;
}
return new Date(v).toLocaleString(locale, timeOptions);
};
export const trim = value => {
if (value && value.trim) {
return value.trim();

View File

@ -1,26 +1,9 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
environmentContainer: {
display: 'flex',
marginBottom: '1rem',
flexWrap: 'wrap',
width: '100%',
position: 'relative',
justifyContent: 'center',
},
environmentMetrics: {
border: `1px solid ${theme.palette.grey[300]}`,
margin: '0.5rem',
width: '30%',
},
[theme.breakpoints.down(1000)]: {
environmentMetrics: { width: '50%' },
},
[theme.breakpoints.down(750)]: {
environmentMetrics: { width: '100%' },
},
secondaryContent: {
marginTop: '1rem',
mobileMarginTop: {
[theme.breakpoints.down('sm')]: {
marginTop: theme.spacing(2),
},
},
}));

View File

@ -1,50 +1,138 @@
import { useParams } from 'react-router';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
import { IFeatureViewParams } from '../../../../interfaces/params';
import useFeatureMetrics from '../../../../hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import FeatureEnvironmentMetrics from '../FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics';
import FeatureSeenApplications from '../FeatureSeenApplications/FeatureSeenApplications';
import { useFeatureMetricsRaw } from '../../../../hooks/api/getters/useFeatureMetricsRaw/useFeatureMetricsRaw';
import PageContent from '../../../common/PageContent';
import { useEffect, useMemo, useState } from 'react';
import {
FEATURE_METRIC_HOURS_BACK_MAX,
FeatureMetricsHours,
} from './FeatureMetricsHours/FeatureMetricsHours';
import { IFeatureMetricsRaw } from '../../../../interfaces/featureToggle';
import { Grid } from '@material-ui/core';
import { FeatureMetricsContent } from './FeatureMetricsContent/FeatureMetricsContent';
import { useQueryStringNumberState } from '../../../../hooks/useQueryStringNumberState';
import { useQueryStringState } from '../../../../hooks/useQueryStringState';
import { FeatureMetricsChips } from './FeatureMetricsChips/FeatureMetricsChips';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
import ConditionallyRender from '../../../common/ConditionallyRender';
import { useStyles } from './FeatureMetrics.styles';
import { getFeatureMetrics } from '../../../../utils/get-feature-metrics';
const FeatureMetrics = () => {
export const FeatureMetrics = () => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const { metrics } = useFeatureMetrics(projectId, featureId);
const environments = useFeatureMetricsEnvironments(projectId, featureId);
const applications = useFeatureMetricsApplications(featureId);
const styles = useStyles();
const featureMetrics = getFeatureMetrics(feature?.environments, metrics);
const [hoursBack = FEATURE_METRIC_HOURS_BACK_MAX, setHoursBack] =
useQueryStringNumberState('hoursBack');
const { featureMetrics } = useFeatureMetricsRaw(featureId, hoursBack);
const metricComponents = featureMetrics.map(metric => {
return (
<FeatureEnvironmentMetrics
key={metric.environment}
className={styles.environmentMetrics}
metric={metric}
/>
);
});
// Keep a cache of the fetched metrics so that we can
// show the cached result while fetching new metrics.
const [cachedMetrics, setCachedMetrics] = useState<
Readonly<IFeatureMetricsRaw[]> | undefined
>(featureMetrics);
useEffect(() => {
featureMetrics && setCachedMetrics(featureMetrics);
}, [featureMetrics]);
const defaultEnvironment = Array.from(environments)[0];
const defaultApplication = Array.from(applications)[0];
const [environment = defaultEnvironment, setEnvironment] =
useQueryStringState('environment');
const [application = defaultApplication, setApplication] =
useQueryStringState('application');
const filteredMetrics = useMemo(() => {
return cachedMetrics
?.filter(metric => metric.environment === environment)
.filter(metric => metric.appName === application);
}, [cachedMetrics, environment, application]);
if (!filteredMetrics) {
return null;
}
return (
<>
<PageContent headerContent="Environment metrics">
<div className={styles.environmentContainer}>
{metricComponents}
</div>
</PageContent>
<PageContent
headerContent="Applications"
className={styles.secondaryContent}
<PageContent headerContent="">
<Grid
container
component="header"
spacing={2}
alignItems="flex-end"
>
<div className={styles.applicationsContainer}>
<FeatureSeenApplications />
</div>
</PageContent>
</>
<Grid item xs={12} md={5}>
<ConditionallyRender
condition={environments.size > 0}
show={
<FeatureMetricsChips
title="Environments"
values={environments}
value={environment}
setValue={setEnvironment}
/>
}
/>
</Grid>
<Grid item xs={12} md={5}>
<ConditionallyRender
condition={applications.size > 0}
show={
<FeatureMetricsChips
title="Applications"
values={applications}
value={application}
setValue={setApplication}
/>
}
/>
</Grid>
<Grid item xs={12} md={2}>
<div className={styles.mobileMarginTop}>
<FeatureMetricsHours
hoursBack={hoursBack}
setHoursBack={setHoursBack}
/>
</div>
</Grid>
</Grid>
<FeatureMetricsContent
metrics={filteredMetrics}
hoursBack={hoursBack}
/>
</PageContent>
);
};
// Get all the environment names for a feature,
// not just the one's we have metrics for.
const useFeatureMetricsEnvironments = (
projectId: string,
featureId: string
): Set<string> => {
const { feature } = useFeature(projectId, featureId);
const environments = feature.environments.map(environment => {
return environment.name;
});
return new Set(environments);
};
// Get all application names for a feature. Fetch apps for the max time range
// so that the list of apps doesn't change when selecting a shorter range.
const useFeatureMetricsApplications = (featureId: string): Set<string> => {
const { featureMetrics = [] } = useFeatureMetricsRaw(
featureId,
FEATURE_METRIC_HOURS_BACK_MAX
);
const applications = featureMetrics.map(metric => {
return metric.appName;
});
return new Set(applications);
};
export default FeatureMetrics;

View File

@ -0,0 +1,68 @@
import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle';
import React, { useMemo } from 'react';
import { Line } from 'react-chartjs-2';
import {
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
TimeScale,
Title,
Tooltip,
} from 'chart.js';
import { useLocationSettings } from '../../../../../hooks/useLocationSettings';
import { FEATURE_METRICS_TABLE_ID } from '../FeatureMetricsTable/FeatureMetricsTable';
import 'chartjs-adapter-date-fns';
import { createChartData } from './createChartData';
import { createChartOptions } from './createChartOptions';
interface IFeatureMetricsChartProps {
metrics: IFeatureMetricsRaw[];
hoursBack: number;
}
export const FeatureMetricsChart = ({
metrics,
hoursBack,
}: IFeatureMetricsChartProps) => {
const { locationSettings } = useLocationSettings();
const sortedMetrics = useMemo(() => {
return [...metrics].sort((metricA, metricB) => {
return metricA.timestamp.localeCompare(metricB.timestamp);
});
}, [metrics]);
const options = useMemo(() => {
return createChartOptions(sortedMetrics, hoursBack, locationSettings);
}, [sortedMetrics, hoursBack, locationSettings]);
const data = useMemo(() => {
return createChartData(sortedMetrics, locationSettings);
}, [sortedMetrics, locationSettings]);
return (
<div style={{ height: 400 }}>
<Line
options={options}
data={data}
aria-label="A line chart with series for all requests, positive requests, and negative requests."
aria-describedby={FEATURE_METRICS_TABLE_ID}
/>
</div>
);
};
// Register dependencies that we need to draw the chart.
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
TimeScale,
Legend,
Tooltip,
Title
);

View File

@ -0,0 +1,51 @@
import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle';
import { ChartData } from 'chart.js';
import { ILocationSettings } from '../../../../../hooks/useLocationSettings';
import theme from '../../../../../themes/main-theme';
import 'chartjs-adapter-date-fns';
interface IPoint {
x: string;
y: number;
}
export const createChartData = (
metrics: IFeatureMetricsRaw[],
locationSettings: ILocationSettings
): ChartData<'line', IPoint[], string> => {
const requestsSeries = {
label: 'total requests',
borderColor: theme.palette.primary.light,
backgroundColor: theme.palette.primary.light,
data: createChartPoints(metrics, locationSettings, m => m.yes + m.no),
};
const yesSeries = {
label: 'exposed',
borderColor: theme.palette.success.light,
backgroundColor: theme.palette.success.light,
data: createChartPoints(metrics, locationSettings, m => m.yes),
};
const noSeries = {
label: 'not exposed',
borderColor: theme.palette.error.light,
backgroundColor: theme.palette.error.light,
data: createChartPoints(metrics, locationSettings, m => m.no),
};
return { datasets: [yesSeries, noSeries, requestsSeries] };
};
const createChartPoints = (
metrics: IFeatureMetricsRaw[],
locationSettings: ILocationSettings,
y: (m: IFeatureMetricsRaw) => number
): IPoint[] => {
const points = metrics.map(metric => ({
x: metric.timestamp,
y: y(metric),
}));
return points.filter(point => point.y > 0);
};

View File

@ -0,0 +1,106 @@
import { ILocationSettings } from '../../../../../hooks/useLocationSettings';
import 'chartjs-adapter-date-fns';
import { ChartOptions, defaults } from 'chart.js';
import { formatTimeWithLocale } from '../../../../common/util';
import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle';
import theme from '../../../../../themes/main-theme';
export const createChartOptions = (
metrics: IFeatureMetricsRaw[],
hoursBack: number,
locationSettings: ILocationSettings
): ChartOptions<'line'> => {
return {
locale: locationSettings.locale,
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
tooltip: {
backgroundColor: 'white',
bodyColor: theme.palette.text.primary,
titleColor: theme.palette.grey[700],
borderColor: theme.palette.primary.main,
borderWidth: 1,
padding: 10,
boxPadding: 5,
usePointStyle: true,
callbacks: {
title: items =>
formatTimeWithLocale(
items[0].parsed.x,
locationSettings.locale
),
},
// Sort tooltip items in the same order as the lines in the chart.
itemSort: (a, b) => b.parsed.y - a.parsed.y,
},
legend: {
position: 'top',
align: 'end',
labels: {
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
},
},
title: {
text: formatChartLabel(hoursBack),
position: 'top',
align: 'start',
display: true,
font: {
size: 16,
weight: '400',
},
},
},
scales: {
y: {
type: 'linear',
title: {
display: true,
text: 'Number of requests',
},
ticks: { precision: 0 },
},
x: {
type: 'time',
time: { unit: 'hour' },
grid: { display: false },
ticks: {
callback: (_, i, data) =>
formatTimeWithLocale(
data[i].value,
locationSettings.locale
),
},
},
},
elements: {
point: {
// If we only have one point, always show a dot (since there's no line).
// If we have multiple points, only show dots on hover (looks better).
radius: metrics.length === 1 ? 6 : 0,
hoverRadius: 6,
},
},
};
};
const formatChartLabel = (hoursBack: number): string => {
return hoursBack === 1
? 'Requests in the last hour'
: `Requests in the last ${hoursBack} hours`;
};
// Set the default font for ticks, legends, tooltips, etc.
defaults.font = {
...defaults.font,
family: 'Sen',
size: 13,
weight: '400',
};

View File

@ -0,0 +1,25 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
title: {
margin: 0,
marginBottom: '.5rem',
fontSize: theme.fontSizes.smallerBody,
fontWeight: theme.fontWeight.thin,
color: theme.palette.grey[600],
},
list: {
display: 'flex',
flexWrap: 'wrap',
gap: '.5rem',
listStyleType: 'none',
padding: 0,
minHeight: '100%',
},
item: {
'& > [aria-pressed=true]': {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
},
},
}));

View File

@ -0,0 +1,48 @@
import { Chip } from '@material-ui/core';
import { useMemo } from 'react';
import { useStyles } from './FeatureMetricsChips.styles';
interface IFeatureMetricsChipsProps {
title: string;
values: Set<string>;
value?: string;
setValue: (value: string) => void;
}
export const FeatureMetricsChips = ({
title,
values,
value,
setValue,
}: IFeatureMetricsChipsProps) => {
const styles = useStyles();
const onClick = (value: string) => () => {
if (values.has(value)) {
setValue(value);
}
};
const sortedValues = useMemo(() => {
return Array.from(values).sort((valueA, valueB) => {
return valueA.localeCompare(valueB);
});
}, [values]);
return (
<div>
<h3 className={styles.title}>{title}</h3>
<ul className={styles.list}>
{sortedValues.map(val => (
<li key={val} className={styles.item}>
<Chip
label={val}
onClick={onClick(val)}
aria-pressed={val === value}
/>
</li>
))}
</ul>
</div>
);
};

View File

@ -0,0 +1,47 @@
import { FeatureMetricsTable } from '../FeatureMetricsTable/FeatureMetricsTable';
import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle';
import { FeatureMetricsStatsRaw } from '../FeatureMetricsStats/FeatureMetricsStatsRaw';
import { FeatureMetricsChart } from '../FeatureMetricsChart/FeatureMetricsChart';
import { FeatureMetricsEmpty } from '../FeatureMetricsEmpty/FeatureMetricsEmpty';
import { Box } from '@material-ui/core';
import theme from '../../../../../themes/main-theme';
interface IFeatureMetricsContentProps {
metrics: IFeatureMetricsRaw[];
hoursBack: number;
}
export const FeatureMetricsContent = ({
metrics,
hoursBack,
}: IFeatureMetricsContentProps) => {
if (metrics.length === 0) {
return (
<Box mt={6}>
<FeatureMetricsEmpty />
</Box>
);
}
return (
<>
<Box
borderTop={1}
pt={2}
mt={3}
borderColor={theme.palette.grey[200]}
>
<FeatureMetricsChart metrics={metrics} hoursBack={hoursBack} />
</Box>
<Box mt={4}>
<FeatureMetricsStatsRaw
metrics={metrics}
hoursBack={hoursBack}
/>
</Box>
<Box mt={4}>
<FeatureMetricsTable metrics={metrics} />
</Box>
</>
);
};

View File

@ -0,0 +1,16 @@
import { Typography } from '@material-ui/core';
export const FeatureMetricsEmpty = () => {
return (
<Typography variant="body1">
<Typography paragraph>
We have yet to receive any metrics for this feature toggle in
the selected time period.
</Typography>
<Typography paragraph>
Please note that, since the SDKs send metrics on an interval, it
might take some time before metrics appear.
</Typography>
</Typography>
);
};

View File

@ -0,0 +1,57 @@
import GeneralSelect, {
OnGeneralSelectChange,
} from '../../../../common/GeneralSelect/GeneralSelect';
interface IFeatureMetricsHoursProps {
hoursBack: number;
setHoursBack: (value: number) => void;
}
export const FEATURE_METRIC_HOURS_BACK_MAX = 48;
export const FeatureMetricsHours = ({
hoursBack,
setHoursBack,
}: IFeatureMetricsHoursProps) => {
const onChange: OnGeneralSelectChange = event => {
setHoursBack(parseFeatureMetricsHour(event.target.value));
};
return (
<GeneralSelect
label="Period"
name="feature-metrics-period"
id="feature-metrics-period"
options={hourOptions}
value={String(hoursBack)}
onChange={onChange}
fullWidth
/>
);
};
const parseFeatureMetricsHour = (value: unknown) => {
switch (value) {
case '1':
return 1;
case '24':
return 24;
default:
return FEATURE_METRIC_HOURS_BACK_MAX;
}
};
const hourOptions: { key: `${number}`; label: string }[] = [
{
key: '1',
label: 'Last hour',
},
{
key: '24',
label: 'Last 24 hours',
},
{
key: `${FEATURE_METRIC_HOURS_BACK_MAX}`,
label: `Last ${FEATURE_METRIC_HOURS_BACK_MAX} hours`,
},
];

View File

@ -0,0 +1,32 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
item: {
padding: theme.spacing(2),
background: theme.palette.grey[100],
borderRadius: theme.spacing(2),
textAlign: 'center',
[theme.breakpoints.up('md')]: {
padding: theme.spacing(4),
},
},
title: {
margin: 0,
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.fontWeight.thin,
},
value: {
fontSize: '2.25rem',
fontWeight: theme.fontWeight.bold,
color: theme.palette.primary.main,
},
text: {
margin: '.5rem 0 0 0',
padding: '1rem 0 0 0',
borderTopWidth: 1,
borderTopStyle: 'solid',
borderTopColor: theme.palette.grey[300],
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.grey[700],
},
}));

View File

@ -0,0 +1,57 @@
import { calculatePercentage } from '../../../../../utils/calculate-percentage';
import { useStyles } from './FeatureMetricsStats.styles';
import { Grid } from '@material-ui/core';
interface IFeatureMetricsStatsProps {
totalYes: number;
totalNo: number;
hoursBack: number;
}
export const FeatureMetricsStats = ({
totalYes,
totalNo,
hoursBack,
}: IFeatureMetricsStatsProps) => {
const styles = useStyles();
const hoursSuffix =
hoursBack === 1 ? 'in the last hour' : `in the last ${hoursBack} hours`;
return (
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<article className={styles.item}>
<h3 className={styles.title}>Exposure</h3>
<p className={styles.value}>{totalYes}</p>
<p className={styles.text}>
Total exposure of the feature in the environment{' '}
{hoursSuffix}.
</p>
</article>
</Grid>
<Grid item xs={12} sm={4}>
<article className={styles.item}>
<h3 className={styles.title}>Exposure %</h3>
<p className={styles.value}>
{calculatePercentage(totalYes + totalNo, totalYes)}%
</p>
<p className={styles.text}>
% total exposure of the feature in the environment{' '}
{hoursSuffix}.
</p>
</article>
</Grid>
<Grid item xs={12} sm={4}>
<article className={styles.item}>
<h3 className={styles.title}>Requests</h3>
<p className={styles.value}>{totalYes + totalNo}</p>
<p className={styles.text}>
Total requests for the feature in the environment{' '}
{hoursSuffix}.
</p>
</article>
</Grid>
</Grid>
);
};

View File

@ -0,0 +1,29 @@
import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle';
import { useMemo } from 'react';
import { FeatureMetricsStats } from './FeatureMetricsStats';
interface IFeatureMetricsStatsRawProps {
metrics: IFeatureMetricsRaw[];
hoursBack: number;
}
export const FeatureMetricsStatsRaw = ({
metrics,
hoursBack,
}: IFeatureMetricsStatsRawProps) => {
const totalYes = useMemo(() => {
return metrics.reduce((acc, m) => acc + m.yes, 0);
}, [metrics]);
const totalNo = useMemo(() => {
return metrics.reduce((acc, m) => acc + m.no, 0);
}, [metrics]);
return (
<FeatureMetricsStats
totalYes={totalYes}
totalNo={totalNo}
hoursBack={hoursBack}
/>
);
};

View File

@ -0,0 +1,71 @@
import { IFeatureMetricsRaw } from '../../../../../interfaces/featureToggle';
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
useMediaQuery,
useTheme,
} from '@material-ui/core';
import { useLocationSettings } from '../../../../../hooks/useLocationSettings';
import { formatFullDateTimeWithLocale } from '../../../../common/util';
import { useMemo } from 'react';
export const FEATURE_METRICS_TABLE_ID = 'feature-metrics-table-id';
interface IFeatureMetricsTableProps {
metrics: IFeatureMetricsRaw[];
}
export const FeatureMetricsTable = ({ metrics }: IFeatureMetricsTableProps) => {
const theme = useTheme();
const smallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { locationSettings } = useLocationSettings();
const sortedMetrics = useMemo(() => {
return [...metrics].sort((metricA, metricB) => {
return metricB.timestamp.localeCompare(metricA.timestamp);
});
}, [metrics]);
if (sortedMetrics.length === 0) {
return null;
}
return (
<Table id={FEATURE_METRICS_TABLE_ID}>
<TableHead>
<TableRow>
<TableCell>Time</TableCell>
<TableCell hidden={smallScreen}>Application</TableCell>
<TableCell hidden={smallScreen}>Environment</TableCell>
<TableCell align="right">Requested</TableCell>
<TableCell align="right">Exposed</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedMetrics.map(metric => (
<TableRow key={metric.timestamp}>
<TableCell>
{formatFullDateTimeWithLocale(
metric.timestamp,
locationSettings.locale
)}
</TableCell>
<TableCell hidden={smallScreen}>
{metric.appName}
</TableCell>
<TableCell hidden={smallScreen}>
{metric.environment}
</TableCell>
<TableCell align="right">
{metric.yes + metric.no}
</TableCell>
<TableCell align="right">{metric.yes}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@ -1,79 +0,0 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
borderRadius: '10px',
backgroundColor: '#fff',
padding: '2rem 2rem 2rem 2rem',
marginBottom: '1rem',
flexDirection: 'column',
width: '50%',
position: 'relative',
},
[theme.breakpoints.down(1000)]: {
container: {
width: '100%',
},
},
headerContainer: {
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
margin: 0,
},
truncator: {
verticalAlign: 'bottom',
},
bodyContainer: {
display: 'flex',
align: 'items',
marginTop: '1rem',
height: '100%',
},
trueCountContainer: {
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
},
trueCount: {
width: '10px',
height: '10px',
borderRadius: '50%',
marginRight: '0.75rem',
backgroundColor: theme.palette.primary.main,
},
falseCount: {
width: '10px',
height: '10px',
borderRadius: '50%',
marginRight: '0.75rem',
backgroundColor: theme.palette.grey[300],
},
paragraph: {
fontSize: theme.fontSizes.smallBody,
},
textContainer: {
marginRight: '1rem',
maxWidth: '150px',
},
primaryMetric: {
width: '100%',
},
icon: {
fill: theme.palette.grey[300],
height: '75px',
width: '75px',
},
chartContainer: {
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
}));

View File

@ -1,141 +0,0 @@
import classNames from 'classnames';
import PercentageCircle from '../../../../common/PercentageCircle/PercentageCircle';
import { useStyles } from './FeatureEnvironmentMetrics.styles';
import { FiberManualRecord } from '@material-ui/icons';
import { useMediaQuery } from '@material-ui/core';
import { IFeatureEnvironmentMetrics } from '../../../../../interfaces/featureToggle';
import { parseISO } from 'date-fns';
import { calculatePercentage } from '../../../../../utils/calculate-percentage';
import StringTruncator from '../../../../common/StringTruncator/StringTruncator';
interface IFeatureEnvironmentProps {
className?: string;
primaryMetric?: boolean;
metric?: IFeatureEnvironmentMetrics;
}
const FeatureEnvironmentMetrics = ({
className,
primaryMetric,
metric,
}: IFeatureEnvironmentProps) => {
const styles = useStyles();
const smallScreen = useMediaQuery(`(max-width:1000px)`);
if (!metric) return null;
const containerClasses = classNames(styles.container, className, {
[styles.primaryMetric]: primaryMetric,
});
let hour = '';
if (metric?.timestamp) {
const metricTime = parseISO(metric.timestamp);
hour = `since ${metricTime.getHours()}:00`;
}
const total = metric.yes + metric.no;
let primaryStyles = {};
if (primaryMetric) {
if (smallScreen) {
primaryStyles = {
width: '60px',
height: '60px',
};
} else {
primaryStyles = {
width: '90px',
height: '90px',
};
}
}
if (metric.yes === 0 && metric.no === 0) {
return (
<div className={containerClasses}>
<div className={styles.headerContainer}>
<h2 data-loading className={styles.title}>
Traffic in&nbsp;
<StringTruncator
text={metric.environment}
className={styles.truncator}
maxWidth="200"
/>
&nbsp;
{hour}
</h2>
</div>
<div className={styles.bodyContainer}>
<div className={styles.textContainer}>
<p className={styles.paragraph} data-loading>
No metrics available for this environment.
</p>
</div>
<div className={styles.chartContainer}>
<FiberManualRecord
style={{ transform: 'scale(1.4)' }}
className={styles.icon}
data-loading
/>
</div>
</div>
</div>
);
}
return (
<div className={containerClasses}>
<div className={styles.headerContainer}>
<h2 data-loading className={styles.title}>
Traffic in&nbsp;
<StringTruncator
text={metric.environment}
maxWidth="150"
className={styles.truncator}
/>
&nbsp;
{hour}
</h2>
</div>
<div className={styles.bodyContainer}>
<div className={styles.textContainer}>
<div className={styles.trueCountContainer}>
<div>
<div className={styles.trueCount} data-loading />
</div>
<p className={styles.paragraph} data-loading>
{metric.yes} users received this feature
</p>
</div>
<div className={styles.trueCountContainer}>
<div>
<div className={styles.falseCount} data-loading />
</div>
<p className={styles.paragraph} data-loading>
{metric.no} users did not receive this feature
</p>
</div>
</div>
<div className={styles.chartContainer} data-loading>
<PercentageCircle
percentage={calculatePercentage(total, metric.yes)}
styles={{
height: '60px',
width: '60px',
marginLeft: '1rem',
...primaryStyles,
transform: 'scale(1.6)',
}}
/>
</div>
</div>
</div>
);
};
export default FeatureEnvironmentMetrics;

View File

@ -68,77 +68,17 @@ export const useStyles = makeStyles(theme => ({
backgroundColor: theme.palette.grey[300],
width: '90%',
},
accordionBodyFooter: {
position: 'relative',
padding: '1rem',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
percentageContainer: {
width: '90px',
height: '90px',
border: `2px solid ${theme.palette.primary.light}`,
borderRadius: '50%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: 'bold',
color: theme.palette.primary.light,
marginTop: '1rem',
},
requestContainer: {
padding: '2rem',
border: `2px solid ${theme.palette.primary.light}`,
borderRadius: '5px',
display: 'flex',
flexDirection: 'column',
maxWidth: '300px',
justifyContent: 'center',
minWidth: '200px',
alignItems: 'center',
},
requestText: {
textAlign: 'center',
marginTop: '1rem',
fontSize: theme.fontSizes.smallBody,
},
linkContainer: {
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '1rem',
},
disabledInfo: {
maxWidth: '300px',
marginRight: '1.5rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
disabledIcon: {
height: '50px',
width: '50px',
fill: theme.palette.grey[400],
marginBottom: '1rem',
},
strategiesText: {
fontSize: '14px',
color: theme.palette.grey[700],
},
stratigiesInfoContainer: {
display: 'flex',
},
noStratigiesInfoContainer: {
top: '1px',
position: 'relative',
},
strategyIconContainer: {
minWidth: '50px',
marginRight: '5px',
display: 'flex',
justifyContent: 'center',
},
strategiesIconsContainer: {
transform: 'scale(0.8)',
display: 'flex',
@ -150,14 +90,6 @@ export const useStyles = makeStyles(theme => ({
top: '5px',
},
},
[theme.breakpoints.down(750)]: {
accordionBodyFooter: {
flexDirection: 'column',
},
requestContainer: {
marginTop: '1rem',
},
},
[theme.breakpoints.down(560)]: {
disabledIndicatorPos: {
top: '13px',
@ -171,15 +103,11 @@ export const useStyles = makeStyles(theme => ({
truncator: {
textAlign: 'center',
},
resultContainer: {
flexWrap: 'wrap',
},
},
[theme.breakpoints.down(400)]: {
accordionHeader: {
padding: '0.5rem 1rem',
},
accordionBodyInnerContainer: {
padding: '0.5rem',
},
@ -223,20 +151,4 @@ export const useStyles = makeStyles(theme => ({
display: 'none',
},
},
resultContainer: {
display: 'flex',
width: '100%',
justifyContent: 'space-around',
},
dataContainer: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignContent: 'center',
alignItems: 'center',
padding: '0px 15px',
},
resultTitle: {
color: theme.palette.primary.main,
},
}));

View File

@ -1,23 +1,19 @@
import {
IFeatureEnvironment,
IFeatureEnvironmentMetrics,
} from '../../../../../../../interfaces/featureToggle';
import { calculatePercentage } from '../../../../../../../utils/calculate-percentage';
import { IFeatureEnvironmentMetrics } from '../../../../../../../interfaces/featureToggle';
import { useStyles } from '../FeatureOverviewEnvironment.styles';
import { FeatureMetricsStats } from '../../../../FeatureMetrics/FeatureMetricsStats/FeatureMetricsStats';
interface IFeatureOverviewEnvironmentFooterProps {
env: IFeatureEnvironment;
environmentMetric?: IFeatureEnvironmentMetrics;
}
const FeatureOverviewEnvironmentFooter = ({
env,
environmentMetric,
}: IFeatureOverviewEnvironmentFooterProps) => {
const styles = useStyles();
if (!environmentMetric) return null;
const totalTraffic = environmentMetric.yes + environmentMetric.no;
if (!environmentMetric) {
return null;
}
return (
<>
@ -26,47 +22,14 @@ const FeatureOverviewEnvironmentFooter = ({
<div className={styles.separatorText}>Result</div>
<div className={styles.rightWing} />
</div>
<div className={styles.accordionBodyFooter}>
<div className={styles.resultContainer}>
<div className={styles.dataContainer}>
<h3 className={styles.resultTitle}>Exposure</h3>
<div className={styles.percentageContainer}>
{environmentMetric?.yes}
</div>
<p className={styles.requestText}>
Total exposure of the feature in the environment in
the last hour
</p>
</div>
<div className={styles.dataContainer}>
<h3 className={styles.resultTitle}>% exposure</h3>
<div className={styles.percentageContainer}>
{calculatePercentage(
totalTraffic,
environmentMetric?.yes
)}
%
</div>
<p className={styles.requestText}>
Total exposure of the feature in the environment in
the last hour
</p>
</div>
<div className={styles.dataContainer}>
<h3 className={styles.resultTitle}>Total requests</h3>
<div className={styles.percentageContainer}>
{environmentMetric?.yes + environmentMetric?.no}
</div>
<p className={styles.requestText}>
The total request of the feature in the environment
in the last hour
</p>
</div>
</div>
<div>
<FeatureMetricsStats
totalYes={environmentMetric.yes}
totalNo={environmentMetric.no}
hoursBack={1}
/>
</div>
</>
);
};
export default FeatureOverviewEnvironmentFooter;

View File

@ -1,15 +0,0 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
listLink: {
color: '#212121',
textDecoration: 'none',
fontWeight: 'normal',
display: 'block',
},
truncate: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}));

View File

@ -1,46 +0,0 @@
import useFeatureMetrics from '../../../../hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import { Link, useParams } from 'react-router-dom';
import { IFeatureViewParams } from '../../../../interfaces/params';
import { Grid } from '@material-ui/core';
import React from 'react';
import { useStyles } from './FeatureSeenApplications.styles';
import ConditionallyRender from '../../../common/ConditionallyRender';
const FeatureSeenApplications: React.FC = () => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { metrics } = useFeatureMetrics(projectId, featureId);
const styles = useStyles();
const seenApplications = (seenApps: string[]) => {
return seenApps.map(appName => {
return (
<Grid item md={4} xs={6} xl={3}>
<Link
to={`/applications/${appName}`}
className={[styles.listLink, styles.truncate].join(' ')}
>
{appName}
</Link>
</Grid>
);
});
};
const noApplications = (
<Grid item xs={12}>
<div>{'Not seen in any applications'}</div>
</Grid>
);
return (
<Grid container spacing={1}>
<hr />
<ConditionallyRender
condition={metrics?.seenApplications?.length > 0}
show={seenApplications(metrics.seenApplications)}
elseShow={noApplications}
/>
</Grid>
);
};
export default FeatureSeenApplications;

View File

@ -1,5 +1,5 @@
import { Tab, Tabs, useMediaQuery } from '@material-ui/core';
import { useState } from 'react';
import React, { useState } from 'react';
import { Archive, FileCopy, Label, WatchLater } from '@material-ui/icons';
import { Link, Route, useHistory, useParams } from 'react-router-dom';
import useFeatureApi from '../../../hooks/api/actions/useFeatureApi/useFeatureApi';
@ -16,10 +16,10 @@ import {
import Dialogue from '../../common/Dialogue';
import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton';
import FeatureLog from './FeatureLog/FeatureLog';
import FeatureMetrics from './FeatureMetrics/FeatureMetrics';
import FeatureOverview from './FeatureOverview/FeatureOverview';
import FeatureStrategies from './FeatureStrategies/FeatureStrategies';
import FeatureVariants from './FeatureVariants/FeatureVariants';
import { FeatureMetrics } from './FeatureMetrics/FeatureMetrics';
import { useStyles } from './FeatureView.styles';
import FeatureSettings from './FeatureSettings/FeatureSettings';
import useLoading from '../../../hooks/useLoading';
@ -29,6 +29,7 @@ import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import StaleDialog from './FeatureOverview/StaleDialog/StaleDialog';
import AddTagDialog from './FeatureOverview/AddTagDialog/AddTagDialog';
import StatusChip from '../../common/StatusChip/StatusChip';
import { formatUnknownError } from '../../../utils/format-unknown-error';
const FeatureView = () => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
@ -60,8 +61,8 @@ const FeatureView = () => {
setShowDelDialog(false);
projectRefetch();
history.push(`/projects/${projectId}`);
} catch (e) {
setToastApiError(e.toString());
} catch (error) {
setToastApiError(formatUnknownError(error));
setShowDelDialog(false);
}
};
@ -160,7 +161,7 @@ const FeatureView = () => {
component={Link}
to={`/projects/${projectId}/features/${featureId}/strategies/copy`}
>
<FileCopy />
<FileCopy titleAccess="Copy" />
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_FEATURE}
@ -169,7 +170,7 @@ const FeatureView = () => {
data-loading
onClick={() => setShowDelDialog(true)}
>
<Archive />
<Archive titleAccess="Archive feature toggle" />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenStaleDialog(true)}
@ -178,7 +179,7 @@ const FeatureView = () => {
tooltip="Toggle stale status"
data-loading
>
<WatchLater />
<WatchLater titleAccess="Toggle stale status" />
</PermissionIconButton>
<PermissionIconButton
onClick={() => setOpenTagDialog(true)}
@ -187,7 +188,7 @@ const FeatureView = () => {
tooltip="Add tag"
data-loading
>
<Label />
<Label titleAccess="Add tag" />
</PermissionIconButton>
</div>
</div>

View File

@ -1,11 +1,8 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import Breadcrumb from '../breadcrumb';
jest.mock('@material-ui/core');
test('breadcrumb for /features', () => {
const tree = renderer.create(
<MemoryRouter initialEntries={['/features']}>

View File

@ -8,7 +8,7 @@ class ScrollToTop extends Component {
};
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
if (this.props.location.pathname !== prevProps.location.pathname) {
if (
this.props.location.pathname.includes('/features/metrics') ||
this.props.location.pathname.includes('/features/variants') ||

View File

@ -0,0 +1,51 @@
import useSWR, { mutate } from 'swr';
import { useCallback } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IFeatureMetricsRaw } from '../../../../interfaces/featureToggle';
const PATH = formatApiPath('api/admin/client-metrics/features');
interface IUseFeatureMetricsRawOutput {
featureMetrics?: Readonly<IFeatureMetricsRaw[]>;
refetchFeatureMetrics: () => void;
loading: boolean;
error?: Error;
}
interface IUseFeatureMetricsRawResponse {
data: IFeatureMetricsRaw[];
}
export const useFeatureMetricsRaw = (
featureId: string,
hoursBack: number
): IUseFeatureMetricsRawOutput => {
const path = formatApiPath(
`api/admin/client-metrics/features/${featureId}/raw?hoursBack=${hoursBack}`
);
const { data, error } = useSWR(path, () => {
return fetchFeatureMetricsRaw(path);
});
const refetchFeatureMetricsRaw = useCallback(() => {
mutate(PATH).catch(console.warn);
}, []);
return {
featureMetrics: data?.data,
loading: !error && !data,
refetchFeatureMetrics: refetchFeatureMetricsRaw,
error,
};
};
const fetchFeatureMetricsRaw = (
path: string
): Promise<IUseFeatureMetricsRawResponse> => {
return fetch(path)
.then(handleErrorResponses('Features'))
.then(res => res.json())
.then();
};

View File

@ -0,0 +1,19 @@
import { useCallback } from 'react';
import { useQueryStringState } from './useQueryStringState';
// Store a number in the query string. Call setState to update the query string.
export const useQueryStringNumberState = (
key: string
): [number | undefined, (value: number) => void] => {
const [value, setValue] = useQueryStringState(key);
const setState = useCallback(
(value: number) => setValue(String(value)),
[setValue]
);
return [
Number.isFinite(Number(value)) ? Number(value) : undefined,
setState,
];
};

View File

@ -0,0 +1,25 @@
import { useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
// Store a value in the query string. Call setState to update the query string.
export const useQueryStringState = (
key: string
): [string | undefined, (value: string) => void] => {
const { search } = window.location;
const { replace } = useHistory();
const params = useMemo(() => {
return new URLSearchParams(search);
}, [search]);
const setState = useCallback(
(value: string) => {
const next = new URLSearchParams(search);
next.set(key, value);
replace({ search: next.toString() });
},
[key, search, replace]
);
return [params.get(key) || undefined, setState];
};

View File

@ -74,3 +74,19 @@ export interface IFeatureMetrics {
lastHourUsage: IFeatureEnvironmentMetrics[];
seenApplications: string[];
}
export interface IFeatureMetrics {
version: number;
maturity: string;
lastHourUsage: IFeatureEnvironmentMetrics[];
seenApplications: string[];
}
export interface IFeatureMetricsRaw {
featureName: string;
appName: string;
environment: string;
timestamp: string;
yes: number;
no: number;
}

View File

@ -122,10 +122,10 @@ const mainTheme = {
},
},
fontWeight: {
thin: '300',
medium: '400',
semi: '700',
bold: '700',
thin: 300,
medium: 400,
semi: 700,
bold: 700,
},
};

View File

@ -3688,6 +3688,16 @@ char-regex@^1.0.2:
resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz"
integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
chart.js@3.7.1:
version "3.7.1"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.7.1.tgz#0516f690c6a8680c6c707e31a4c1807a6f400ada"
integrity sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==
chartjs-adapter-date-fns@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b"
integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==
check-more-types@^2.24.0:
version "2.24.0"
resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz"
@ -10180,6 +10190,11 @@ react-app-polyfill@^2.0.0:
regenerator-runtime "^0.13.7"
whatwg-fetch "^3.4.1"
react-chartjs-2@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.0.1.tgz#38ed1d5b6e2f789408450c981e9e8e1d78b2e015"
integrity sha512-q8bgWzKoFvBvD7YcjT/hXG8jt55TaMAuJ1dmI3tKFJ7CijUWYz4pIfOhkTI6PBTwqu/pmeWsClBRd/7HiWzN1g==
react-dev-utils@^11.0.3:
version "11.0.4"
resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz"
@ -11737,10 +11752,10 @@ svgo@^1.0.0, svgo@^1.2.2:
unquote "~1.1.1"
util.promisify "~1.0.0"
swr@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/swr/-/swr-1.2.1.tgz#c21a4fe2139cb1c4630450589b5b5add947a9d41"
integrity sha512-1cuWXqJqXcFwbgONGCY4PHZ8v05009JdHsC3CIC6u7d00kgbMswNr1sHnnhseOBxtzVqcCNpOHEgVDciRer45w==
swr@1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/swr/-/swr-1.2.2.tgz#6cae09928d30593a7980d80f85823e57468fac5d"
integrity sha512-ky0BskS/V47GpW8d6RU7CPsr6J8cr7mQD6+do5eky3bM0IyJaoi3vO8UhvrzJaObuTlGhPl2szodeB2dUd76Xw==
symbol-observable@1.2.0:
version "1.2.0"