Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React Timeline Chart and Table Component #8562

Merged
merged 1 commit into from
Feb 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion app/controllers/application_controller/timelines.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ def tl_chooser
page << javascript_prologue
page.replace("flash_msg_div", :partial => "layouts/flash_msg")
page << "miqScrollTop();" if @flash_array.present?
page.replace("tl_div", :partial => "layouts/tl_detail")
page << "ManageIQ.calendar.calDateFrom = new Date(#{@tl_options.date.start});" unless @tl_options.date.start.nil?
page << "ManageIQ.calendar.calDateTo = new Date(#{@tl_options.date.end});" unless @tl_options.date.end.nil?
page << 'miqBuildCalendar();'
Expand Down
56 changes: 56 additions & 0 deletions app/javascript/components/timeline-options/timeline-chart.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { LineChart } from '@carbon/charts-react';
import { timelineUiOptions } from './timeline-helper';

const TimelineChart = ({ data, title, buildTableData }) => {
const chartRef = useRef(null);
const chartOnClick = ({ detail }) => buildTableData(detail.datum);

useEffect(() => {
chartRef.current.chart.services.events.addEventListener(
'scatter-click',
chartOnClick
);
}, [chartRef]);

// Unmount
useEffect(
() => () => {
if (chartRef.current) {
chartRef.current.chart.services.events.removeEventListener(
'scatter-click',
chartOnClick
);
}
},
[]
);

const LengthWarning = (
<label className="bx--label">
{__('*Only 5000 events shown. Limit date range to avoid \"missing\" events.')}
</label>
);

return (
<div>
{data.length >= 5000 && LengthWarning}
<LineChart className="line_charts" data={data} options={timelineUiOptions(title)} ref={chartRef} />
</div>
);
};

TimelineChart.propTypes = {
data: PropTypes.arrayOf(PropTypes.any),
title: PropTypes.string,
buildTableData: PropTypes.func,
};

TimelineChart.defaultProps = {
data: [],
title: '',
buildTableData: () => {},
};

export default TimelineChart;
111 changes: 111 additions & 0 deletions app/javascript/components/timeline-options/timeline-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
export const timelineUiOptions = (title) => ({
title,
axes: {
bottom: {
title: __('Date'),
mapsTo: 'date',
scaleType: 'time',
},
left: {
title: __('# of Events'),
mapsTo: 'value',
scaleType: 'linear',
},
},
legend: {
clickable: true,
},
tooltip: {
totalLabel: __('Total Events'),
},
points: {
radius: 3,
fillOpacity: 1,
filled: true,
enabled: true,
},
zoomBar: {
top: {
className: 'zoom-bar',
enabled: true,
type: 'graph_view',
},
},
height: '400px',
});

const smartAndOrStatements = (group, array) => {
const item = (array.length === 1) ? array[0] : `[${array.toString()}]`;
return `&filter[]=${group}=${item}`;
};

/** Function to build the URL to get all events that fall under the user selected parameters. */
export const buildUrl = (values) => {
// TODO: Different timeline show different data, ensure all necesary data is pulled
let url = `/api/event_streams?limit=5000&offset=0&expand=resources`;
url += `&attributes=group,group_level,group_name,id,event_type,ems_id,type,timestamp,created_on,host,source`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed you removed host.name - won't that make it a lot more expensive if it's returning the entire host object?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was causing the issue 4) above. That was removed and I added issue 7) to add the proper ems_id/host_id filter[] as we're not filtering properly right now.

Copy link
Member

@jrafanie jrafanie Feb 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking of either a virtual column or something that would make it more reliable as the host.name sometimes comes back and others it doesn't from the same API request. It makes the logic needlessly complex in the UI.

Example API response without host.name:

        {
            "href": "http://localhost:3000/api/event_streams/48428",
            "id": "48428",
            "event_type": "MODIFY_URI",
            "ems_id": "2",
            "type": "EmsEvent",
            "timestamp": "2022-10-05T04:00:31Z",
            "created_on": "2022-10-05T03:29:55Z",
            "source": "IBM_POWER_HMC",
            "host_id": "1",
            "group": "configuration",
            "group_level": "detail",
            "group_name": "Configuration/Reconfiguration"
        },

and with a host.name(from the same API request):


        {
            "href": "http://localhost:3000/api/event_streams/67912",
            "id": "67912",
            "event_type": "MODIFY_URI",
            "ems_id": "2",
            "type": "EmsEvent",
            "timestamp": "2022-10-06T09:58:54Z",
            "created_on": "2022-10-06T09:28:19Z",
            "source": "IBM_POWER_HMC",
            "host_id": "7",
            "group": "configuration",
            "group_level": "detail",
            "group_name": "Configuration/Reconfiguration",
            "host": {
                "name": "porthos"
            }
        },

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've opened this PR to add virtual columns to address this problem: ManageIQ/manageiq#22398


// User set values
url += `&filter[]=type=${values.type}`;
url += smartAndOrStatements('group', values.group);
url += smartAndOrStatements('group_level', values.group_level);

if (values.start_date) {
url += `&filter[]=timestamp%3E${values.start_date[0].toISOString()}`;
}
if (values.end_date) {
url += `&filter[]=timestamp%3C${values.end_date[0].toISOString()}`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These values are assumed to be there. Maybe these should be smart defaults or perhaps we raise an error if we're unsure.

}
return url;
};

/** Function to format the raw data such that it can be used by the Timeline Chart component */
export const buildChartDataObject = (rawData) => {
// https://codesandbox.io/s/tender-river-9t3vu5?file=/src/index.js
// Link to how a dataset object should look like
const datasets = [];
rawData.resources.forEach((event) => {
const idx = datasets.findIndex((element) => {
if (element.date === event.timestamp && element.group === event.group_name) {
return true;
}
return false;
});
if (datasets[idx]) {
datasets[idx].value += 1;
datasets[idx].eventsObj.push(event);
} else { // idx comes back as -1 when its undefined
const obj = {
group: event.group_name,
date: event.timestamp ? event.timestamp : event.created_on,
value: 1,
eventsObj: [event],
};
datasets.push(obj);
}
});
return datasets;
};

/** Function to go through all the data pulled by the form submit and finds the right objects */
export const buildDataTableObject = (pointChoice) => {
const tableData = [];
pointChoice.eventsObj.forEach((event) => {
// TODO: Different timeline show different data, ensure all necesary data is included
const eventObj = {
event_type: event.event_type,
source: event.source,
group_level: event.group_level,
provider: '', // TODO hyperlink
provider_username: '',
message: '',
host: (event.host === null) ? '' : event.host.name, // TODO hyperlink
source_vm: '', // TODO hyperlink
source_vm_location: '',
timestamp: event.timestamp, // is Zulu time, should convert to user's timezone
id: event.id,
};
tableData.push(eventObj);
});
return tableData;
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { componentTypes, validatorTypes } from '@@ddf';

const getOneWeekAgo = () => {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return [oneWeekAgo];
};

jeffibm marked this conversation as resolved.
Show resolved Hide resolved
const createSchemaSimple = (
timelineEvents, managementGroupNames, managementGroupLevels, policyGroupNames, policyGroupLevels
) => ({
Expand Down Expand Up @@ -141,12 +147,14 @@ const createSchemaSimple = (
id: 'startDate',
name: 'startDate',
label: __('Start Date'),
initialValue: getOneWeekAgo(),
},
{
component: 'date-picker',
id: 'endDate',
name: 'endDate',
label: __('End Date'),
initialValue: [new Date()],
},
],
},
Expand Down
25 changes: 11 additions & 14 deletions app/javascript/components/timeline-options/timeline-options.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import createSchemaSimple from './timeline-options-simple.schema';
import mapper from '../../forms/mappers/componentMapper';

const TimelineOptions = ({ url }) => {
const TimelineOptions = ({ submitChosenFormOptions }) => {
const [{
isLoading, timelineEvents, managementGroupNames, managementGroupLevels, policyGroupNames, policyGroupLevels,
}, setState] = useState({
Expand All @@ -25,7 +25,7 @@ const TimelineOptions = ({ url }) => {
// Management Events
Object.entries(data.EmsEvent.group_names).forEach((entry) => {
const [key, value] = entry;
managementGroupNames.push({ label: value, value });
managementGroupNames.push({ label: value, value: key });
});
Object.entries(data.EmsEvent.group_levels).forEach((entry) => {
const [key, value] = entry;
Expand Down Expand Up @@ -59,18 +59,15 @@ const TimelineOptions = ({ url }) => {
});

const onSubmit = (values) => {
miqSparkleOn();
const show = values.timelineEvents === 'EmsEvent' ? 'timeline' : 'policy_timeline';
const categories = values.timelineEvents === 'EmsEvent' ? values.managementGroupNames : values.policyGroupNames;
const vmData = {
tl_show: show,
tl_categories: categories,
tl_levels: values.managementGroupLevels ? values.managementGroupLevels : [],
tl_result: values.policyGroupLevels ? values.policyGroupLevels : 'success',
const newData = {
type: values.timelineEvents,
group: categories,
group_level: values.managementGroupLevels ? values.managementGroupLevels : [values.policyGroupLevels],
start_date: values.startDate,
end_date: values.endDate,
};
window.ManageIQ.calendar.calDateFrom = values.startDate;
window.ManageIQ.calendar.calDateTo = values.endDate;
window.miqJqueryRequest(url, { beforeSend: true, data: vmData });
submitChosenFormOptions(newData);
};

return !isLoading && (
Expand All @@ -88,11 +85,11 @@ const TimelineOptions = ({ url }) => {
};

TimelineOptions.propTypes = {
url: PropTypes.string,
submitChosenFormOptions: PropTypes.func,
};

TimelineOptions.defaultProps = {
url: '',
submitChosenFormOptions: undefined,
};

export default TimelineOptions;
57 changes: 57 additions & 0 deletions app/javascript/components/timeline-options/timeline-page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import TimelineOptions from './timeline-options';
import TimelineChart from './timeline-chart';
import TimelineTable from './timeline-table';
import NoRecordsFound from '../no-records-found';
import { buildUrl, buildChartDataObject, buildDataTableObject } from './timeline-helper';

const TimelinePage = () => {
const [{
timelineChartData, timelineTableData, submitWasPressed,
}, setState] = useState({
timelineFormChoices: {},
timelineResources: [],
timelineChartData: [],
timelineTableData: [],
submitWasPressed: false,
});

const submitChosenFormOptions = (formChoices) => {
miqSparkleOn();
API.get(buildUrl(formChoices)).then((chartValues) => {
miqSparkleOff();
setState((state) => ({
...state,
timelineFormChoices: formChoices,
timelineResources: chartValues.resources,
timelineChartData: buildChartDataObject(chartValues),
timelineTableData: [],
submitWasPressed: true,
}));
});
};

const buildTableData = (pointChosen) => {
setState((state) => ({
...state,
timelineTableData: buildDataTableObject(pointChosen),
}));
};

const timelineComponent = submitWasPressed ? timelineChartData.length === 0 ? <NoRecordsFound />
: <TimelineChart data={timelineChartData} title={__('Timeline Data')} buildTableData={buildTableData} />
: <></>;

const timelineTableComponent = timelineTableData.length === 0 ? <></>
: <TimelineTable data={timelineTableData} />;

return (
<>
<TimelineOptions submitChosenFormOptions={submitChosenFormOptions} />
{timelineComponent}
{timelineTableComponent}
</>
);
};

export default TimelinePage;
77 changes: 77 additions & 0 deletions app/javascript/components/timeline-options/timeline-table.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from 'react';
import PropTypes from 'prop-types';
import MiqDataTable from '../miq-data-table';

const TimelineTable = ({ data }) => {
// TODO Commented out columns exist in certain pages but not other
// we need to figure out how to distinguish when which columns are used
const headers = [
{
key: 'event_type',
header: __('Event Type'),
},
{
key: 'source',
header: __('Event Source'),
},
{
key: 'group_level',
header: __('Group Level'),
},
// {
// key: 'provider',
// header: __('Provider'),
// },
// {
// key: 'provider_username',
// header: __('Provider User Name'),
// },
// {
// key: 'message',
// header: __('Message'),
// },
{
key: 'host',
header: __('Source Host'),
},
// {
// key: 'source_vm',
// header: __('Source VM'),
// },
// {
// key: 'source_vm_location',
// header: __('Source VM Location'),
// },
{
key: 'timestamp',
header: __('Date Time'),
},
];
/**
* NOTE: In the original tables, the information displayed was different
* depending from where you accessed the timeline page from
*
* Ex. 'Source VM Location' would now appear on the Host page, but it would
* for the VM & Templates page
*/

return (
<div className="timeline-data-table">
<MiqDataTable
rows={data}
headers={headers}
/>
</div>

);
};

TimelineTable.propTypes = {
data: PropTypes.arrayOf(PropTypes.any),
};

TimelineTable.defaultProps = {
data: [],
};

export default TimelineTable;