Skip to content

Commit

Permalink
#32 feat: frontend supports filtering reports before an aggregation (#34
Browse files Browse the repository at this point in the history
)

* #32 feat: frontend supports filtering reports before an aggregation

Signed-off-by: Long Zhang <long.zhang@electrolux.com>

* #32 add a label that shows whether filters are activated or not

Signed-off-by: Long Zhang <long.zhang@electrolux.com>

* fix code style

Signed-off-by: Long Zhang <long.zhang@electrolux.com>

---------

Signed-off-by: Long Zhang <long.zhang@electrolux.com>
  • Loading branch information
gluckzhang authored Jul 1, 2024
1 parent 1ffc2b8 commit 9c1651b
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 11 deletions.
38 changes: 37 additions & 1 deletion plugins/infrawallet/src/api/functions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { format, parse, subMonths } from 'date-fns';
import { reduce } from 'lodash';
import moment from 'moment';
import { Report } from './types';
import { Report, Filters } from './types';

export const mergeCostReports = (reports: Report[], threshold: number): Report[] => {
const totalCosts: { id: string; total: number }[] = [];
Expand Down Expand Up @@ -44,6 +44,20 @@ export const mergeCostReports = (reports: Report[], threshold: number): Report[]
return Object.values(mergedReports);
};

export const filterCostReports = (reports: Report[], filters: Filters): Report[] => {
const filteredReports = reports.filter(report => {
let match = true;
Object.keys(filters).forEach(key => {
if (filters[key].length > 0 && !filters[key].includes(report[key] as string)) {
match = false;
}
});
return match;
});

return filteredReports;
};

export const aggregateCostReports = (reports: Report[], aggregatedBy?: string): Report[] => {
const aggregatedReports: { [key: string]: Report } = reduce(
reports,
Expand Down Expand Up @@ -84,6 +98,28 @@ export const aggregateCostReports = (reports: Report[], aggregatedBy?: string):
return Object.values(aggregatedReports);
};

export const getReportKeyAndValues = (reports: Report[]): { [key: string]: string[] } => {
const excludedKeys = ['id', 'reports'];
const keyValueSets: { [key: string]: Set<string> } = {};
reports.forEach(report => {
Object.keys(report).forEach(key => {
if (!excludedKeys.includes(key)) {
if (keyValueSets[key] === undefined) {
keyValueSets[key] = new Set<string>();
} else {
keyValueSets[key].add(report[key] as string);
}
}
});
});

const keyValues: { [key: string]: string[] } = {};
Object.keys(keyValueSets).forEach((key: string) => {
keyValues[key] = Array.from(keyValueSets[key]);
});
return keyValues;
};

export const getAllReportTags = (reports: Report[]): string[] => {
const tags = new Set<string>();
const reservedKeys = ['id', 'name', 'service', 'category', 'provider', 'reports'];
Expand Down
4 changes: 4 additions & 0 deletions plugins/infrawallet/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export type Report = {
};
};

export type Filters = {
[key: string]: string[];
};

export type CloudProviderError = {
provider: string; // AWS, GCP or Azure
name: string; // the name defined in the configuration file
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Box, FormControl } from '@material-ui/core';
import Button from '@material-ui/core/Button';
import Checkbox from '@material-ui/core/Checkbox';
import TextField from '@material-ui/core/TextField';
import { makeStyles } from '@material-ui/core/styles';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import Autocomplete from '@material-ui/lab/Autocomplete';
import React, { FC } from 'react';
import { getReportKeyAndValues } from '../../api/functions';
import { FiltersComponentProps } from '../types';

const useStyles = makeStyles(theme => ({
formControl: {
marginLeft: theme.spacing(1),
marginRight: theme.spacing(3),
width: 300,
},
}));
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;

export const FiltersComponent: FC<FiltersComponentProps> = ({ reports, filters, filtersSetter }) => {
const classes = useStyles();
const keyValues: { [key: string]: string[] } = getReportKeyAndValues(reports);
const handleFiltersChange = (key: string, newValue: string[]): void => {
filtersSetter({ ...filters, [key]: newValue });
};

return (
<Box>
{Object.keys(keyValues).map(key => (
<FormControl className={classes.formControl} key={`form-${key}`}>
<Autocomplete
multiple
id={`checkboxes-${key}`}
options={keyValues[key]}
value={filters[key] || []}
onChange={(_event, value: string[], _reason) => handleFiltersChange(key, value)}
disableCloseOnSelect
renderOption={(option, { selected }) => (
<React.Fragment key={`option-${option}`}>
<Checkbox icon={icon} checkedIcon={checkedIcon} style={{ marginRight: 8 }} checked={selected} />
{option}
</React.Fragment>
)}
renderInput={params => <TextField {...params} variant="standard" label={key} />}
/>
</FormControl>
))}
<FormControl className={classes.formControl} style={{ marginTop: 10 }}>
<Button variant="contained" color="primary" onClick={() => filtersSetter({})}>
Clear filters
</Button>
</FormControl>
</Box>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { FiltersComponent } from './FiltersComponent';
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { Content, Header, Page, Progress } from '@backstage/core-components';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import { Grid } from '@material-ui/core';
import { Chip, Grid } from '@material-ui/core';
import Accordion from '@material-ui/core/Accordion';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import Typography from '@material-ui/core/Typography';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { addMonths, endOfMonth, startOfMonth } from 'date-fns';
import React, { useCallback, useEffect, useState } from 'react';
import { infraWalletApiRef } from '../../api/InfraWalletApi';
import { aggregateCostReports, getAllReportTags, getPeriodStrings, mergeCostReports } from '../../api/functions';
import { CloudProviderError, Report } from '../../api/types';
import {
aggregateCostReports,
filterCostReports,
getAllReportTags,
getPeriodStrings,
mergeCostReports,
} from '../../api/functions';
import { CloudProviderError, Filters, Report } from '../../api/types';
import { ColumnsChartComponent } from '../ColumnsChartComponent';
import { CostReportsTableComponent } from '../CostReportsTableComponent';
import { ErrorsAlertComponent } from '../ErrorsAlertComponent';
import { FiltersComponent } from '../FiltersComponent';
import { PieChartComponent } from '../PieChartComponent';
import { TopbarComponent } from '../TopbarComponent';
import { MonthRange } from '../types';
Expand All @@ -33,17 +45,27 @@ const rearrangeData = (report: Report, periods: string[]): any[] => {
return costs;
};

const checkIfFiltersActivated = (filters: Filters): boolean => {
let activated = false;
Object.keys(filters).forEach((key: string) => {
if (filters[key].length > 0) {
activated = true;
}
});
return activated;
};

export const ReportsComponent = () => {
const MERGE_THRESHOLD = 8;
const [submittingState, setSubmittingState] = useState<Boolean>(false);
const [reports, setReports] = useState<Report[]>([]);
const [filters, setFilters] = useState<Filters>({});
const [cloudProviderErrors, setCloudProviderErrors] = useState<CloudProviderError[]>([]);
const [reportsAggregated, setReportsAggregated] = useState<Report[]>([]);
const [reportsAggregatedAndMerged, setReportsAggregatedAndMerged] = useState<Report[]>([]);
const [reportTags, setReportTags] = useState<string[]>([]);
const [granularity, setGranularity] = useState<string>('monthly');
const [aggregatedBy, setAggregatedBy] = useState<string>('none');
const [filters, _setFilters] = useState<string>('');
const [groups, _setGroups] = useState<string>('');
const [monthRangeState, setMonthRangeState] = React.useState<MonthRange>({
startMonth: startOfMonth(addMonths(new Date(), -2)),
Expand All @@ -57,7 +79,7 @@ export const ReportsComponent = () => {
const fetchCostReportsCallback = useCallback(async () => {
setSubmittingState(true);
await infraWalletApi
.getCostReports(filters, groups, granularity, monthRangeState.startMonth, monthRangeState.endMonth)
.getCostReports('', groups, granularity, monthRangeState.startMonth, monthRangeState.endMonth)
.then(reportsResponse => {
if (reportsResponse.data && reportsResponse.data.length > 0) {
setReports(reportsResponse.data);
Expand All @@ -69,18 +91,19 @@ export const ReportsComponent = () => {
})
.catch(e => alertApi.post({ message: `${e.message}`, severity: 'error' }));
setSubmittingState(false);
}, [filters, groups, monthRangeState, granularity, infraWalletApi, alertApi]);
}, [groups, monthRangeState, granularity, infraWalletApi, alertApi]);

useEffect(() => {
if (reports.length !== 0) {
const arrgegatedReports = aggregateCostReports(reports, aggregatedBy);
const filteredReports = filterCostReports(reports, filters);
const arrgegatedReports = aggregateCostReports(filteredReports, aggregatedBy);
const aggregatedAndMergedReports = mergeCostReports(arrgegatedReports, MERGE_THRESHOLD);
const allTags = getAllReportTags(reports);
setReportsAggregated(arrgegatedReports);
setReportsAggregatedAndMerged(aggregatedAndMergedReports);
setReportTags(allTags);
}
}, [reports, aggregatedBy, granularity, monthRangeState]);
}, [filters, reports, aggregatedBy, granularity, monthRangeState]);

useEffect(() => {
fetchCostReportsCallback();
Expand All @@ -90,6 +113,7 @@ export const ReportsComponent = () => {
<Page themeId="tool">
<Header title="InfraWallet" />
<Content>
{submittingState ? <Progress /> : null}
<Grid container spacing={3}>
{cloudProviderErrors.length > 0 && (
<Grid item xs={12}>
Expand All @@ -106,7 +130,16 @@ export const ReportsComponent = () => {
/>
</Grid>
<Grid item xs={12}>
{submittingState ? <Progress /> : null}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls="filters-content" id="filters-header">
<Typography>
Filters {checkIfFiltersActivated(filters) && <Chip size="small" label="active" color="primary" />}
</Typography>
</AccordionSummary>
<AccordionDetails>
<FiltersComponent reports={reports} filters={filters} filtersSetter={setFilters} />
</AccordionDetails>
</Accordion>
</Grid>
<Grid item xs={12} md={4} lg={3}>
{reportsAggregatedAndMerged.length > 0 && (
Expand Down
8 changes: 7 additions & 1 deletion plugins/infrawallet/src/components/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Report } from '../api/types';
import { Report, Filters } from '../api/types';

export type TrendBarComponentProps = {
categories: any[];
Expand All @@ -20,6 +20,12 @@ export type TopbarComponentProps = {
monthRangeSetter: any;
};

export type FiltersComponentProps = {
reports: Report[];
filters: Filters;
filtersSetter: any;
};

export type QueryComponentProps = {
filters: string;
filtersSetter: any;
Expand Down

0 comments on commit 9c1651b

Please sign in to comment.