mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Merge branch 'main' into feat/redirect
This commit is contained in:
		
						commit
						79031bfb48
					
				@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
    mobileMarginTop: {
 | 
			
		||||
        [theme.breakpoints.down('sm')]: {
 | 
			
		||||
            marginTop: theme.spacing(2),
 | 
			
		||||
        },
 | 
			
		||||
    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',
 | 
			
		||||
    },
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
@ -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 />
 | 
			
		||||
                <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, { Suspense, 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,7 +16,6 @@ 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';
 | 
			
		||||
@ -29,6 +28,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 +60,8 @@ const FeatureView = () => {
 | 
			
		||||
            setShowDelDialog(false);
 | 
			
		||||
            projectRefetch();
 | 
			
		||||
            history.push(`/projects/${projectId}`);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
            setToastApiError(e.toString());
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            setToastApiError(formatUnknownError(error));
 | 
			
		||||
            setShowDelDialog(false);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
@ -160,7 +160,7 @@ const FeatureView = () => {
 | 
			
		||||
                                    component={Link}
 | 
			
		||||
                                    to={`/projects/${projectId}/features/${featureId}/strategies/copy`}
 | 
			
		||||
                                >
 | 
			
		||||
                                    <FileCopy />
 | 
			
		||||
                                    <FileCopy titleAccess="Copy" />
 | 
			
		||||
                                </PermissionIconButton>
 | 
			
		||||
                                <PermissionIconButton
 | 
			
		||||
                                    permission={DELETE_FEATURE}
 | 
			
		||||
@ -169,7 +169,7 @@ const FeatureView = () => {
 | 
			
		||||
                                    data-loading
 | 
			
		||||
                                    onClick={() => setShowDelDialog(true)}
 | 
			
		||||
                                >
 | 
			
		||||
                                    <Archive />
 | 
			
		||||
                                    <Archive titleAccess="Archive feature toggle" />
 | 
			
		||||
                                </PermissionIconButton>
 | 
			
		||||
                                <PermissionIconButton
 | 
			
		||||
                                    onClick={() => setOpenStaleDialog(true)}
 | 
			
		||||
@ -178,7 +178,7 @@ const FeatureView = () => {
 | 
			
		||||
                                    tooltip="Toggle stale status"
 | 
			
		||||
                                    data-loading
 | 
			
		||||
                                >
 | 
			
		||||
                                    <WatchLater />
 | 
			
		||||
                                    <WatchLater titleAccess="Toggle stale status" />
 | 
			
		||||
                                </PermissionIconButton>
 | 
			
		||||
                                <PermissionIconButton
 | 
			
		||||
                                    onClick={() => setOpenTagDialog(true)}
 | 
			
		||||
@ -187,7 +187,7 @@ const FeatureView = () => {
 | 
			
		||||
                                    tooltip="Add tag"
 | 
			
		||||
                                    data-loading
 | 
			
		||||
                                >
 | 
			
		||||
                                    <Label />
 | 
			
		||||
                                    <Label titleAccess="Add tag" />
 | 
			
		||||
                                </PermissionIconButton>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
@ -203,6 +203,7 @@ const FeatureView = () => {
 | 
			
		||||
                            </Tabs>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <Suspense fallback={null}>
 | 
			
		||||
                        <Route
 | 
			
		||||
                            exact
 | 
			
		||||
                            path={`/projects/:projectId/features/:featureId`}
 | 
			
		||||
@ -214,7 +215,7 @@ const FeatureView = () => {
 | 
			
		||||
                        />
 | 
			
		||||
                        <Route
 | 
			
		||||
                            path={`/projects/:projectId/features/:featureId/metrics`}
 | 
			
		||||
                        component={FeatureMetrics}
 | 
			
		||||
                            component={FeatureMetricsLazy}
 | 
			
		||||
                        />
 | 
			
		||||
                        <Route
 | 
			
		||||
                            path={`/projects/:projectId/features/:featureId/logs`}
 | 
			
		||||
@ -228,6 +229,7 @@ const FeatureView = () => {
 | 
			
		||||
                            path={`/projects/:projectId/features/:featureId/settings`}
 | 
			
		||||
                            component={FeatureSettings}
 | 
			
		||||
                        />
 | 
			
		||||
                    </Suspense>
 | 
			
		||||
                    <Dialogue
 | 
			
		||||
                        onClick={() => archiveToggle()}
 | 
			
		||||
                        open={showDelDialog}
 | 
			
		||||
@ -254,4 +256,8 @@ const FeatureView = () => {
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const FeatureMetricsLazy = React.lazy(
 | 
			
		||||
    () => import('./FeatureMetrics/FeatureMetrics')
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export default FeatureView;
 | 
			
		||||
 | 
			
		||||
@ -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"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user