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:
commit
aeaea9602c
@ -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);
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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}
|
||||
|
@ -3,7 +3,7 @@ import { useStyles } from './StatusChip.styles';
|
||||
|
||||
interface IStatusChip {
|
||||
stale: boolean;
|
||||
showActive: true;
|
||||
showActive?: true;
|
||||
}
|
||||
|
||||
const StatusChip = ({ stale, showActive = true }: IStatusChip) => {
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
);
|
@ -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);
|
||||
};
|
@ -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',
|
||||
};
|
@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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`,
|
||||
},
|
||||
];
|
@ -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],
|
||||
},
|
||||
}));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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
|
||||
<StringTruncator
|
||||
text={metric.environment}
|
||||
className={styles.truncator}
|
||||
maxWidth="200"
|
||||
/>
|
||||
|
||||
{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
|
||||
<StringTruncator
|
||||
text={metric.environment}
|
||||
maxWidth="150"
|
||||
className={styles.truncator}
|
||||
/>
|
||||
|
||||
{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;
|
@ -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,
|
||||
},
|
||||
}));
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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>
|
||||
|
@ -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']}>
|
||||
|
@ -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') ||
|
||||
|
@ -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();
|
||||
};
|
19
frontend/src/hooks/useQueryStringNumberState.ts
Normal file
19
frontend/src/hooks/useQueryStringNumberState.ts
Normal 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,
|
||||
];
|
||||
};
|
25
frontend/src/hooks/useQueryStringState.ts
Normal file
25
frontend/src/hooks/useQueryStringState.ts
Normal 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];
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -122,10 +122,10 @@ const mainTheme = {
|
||||
},
|
||||
},
|
||||
fontWeight: {
|
||||
thin: '300',
|
||||
medium: '400',
|
||||
semi: '700',
|
||||
bold: '700',
|
||||
thin: 300,
|
||||
medium: 400,
|
||||
semi: 700,
|
||||
bold: 700,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user